throughline 0.5.0 → 0.6.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.
Files changed (51) hide show
  1. package/.codex-sidecar.yml +5 -0
  2. package/CHANGELOG.md +60 -2
  3. package/README.ja.md +37 -21
  4. package/README.md +79 -29
  5. package/bin/throughline.mjs +10 -0
  6. package/docs/00_overview.md +34 -0
  7. package/docs/{L1_L2_L3_REDESIGN.md → 01_l1_l2_l3_redesign.md} +3 -3
  8. package/docs/{THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md → 02_clear_auto_handoff_plan.md} +6 -6
  9. package/docs/{INHERITANCE_ON_CLEAR_ONLY.md → 03_inheritance_on_clear_only.md} +3 -3
  10. package/docs/{PUBLIC_RELEASE_PLAN.md → 04_public_release_plan.md} +3 -3
  11. package/docs/{THROUGHLINE_CODEX_FIRST_ROADMAP.md → 05_codex_first_roadmap.md} +9 -9
  12. package/docs/{THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md → 06_codex_trim_rollback_fix_plan.md} +6 -6
  13. package/docs/{THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md → 07_codex_trim_implementation_plan.md} +10 -10
  14. package/docs/{THROUGHLINE_CODEX_DUAL_SUPPORT.md → 08_codex_dual_support.md} +8 -8
  15. package/docs/{throughline-rollback-context-trim-insight.md → 09_rollback_context_trim_insight.md} +5 -5
  16. package/docs/{THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md → 10_transcript_injection_plan.md} +6 -6
  17. package/docs/{THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md → 11_codex_monitor_implementation_plan.md} +1 -1
  18. package/docs/12_desktop_clear_handoff_plan.md +215 -0
  19. package/docs/adr/0001-claude-primary-codex-adapter.md +22 -0
  20. package/docs/archive/README.md +3 -3
  21. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +3 -3
  22. package/package.json +2 -1
  23. package/rag/01-hooks/raw/session-end-reasons.md +21 -0
  24. package/{docs/RAG → rag}/INDEX.md +20 -16
  25. package/src/auditor-context.mjs +330 -0
  26. package/src/auditor-context.test.mjs +296 -0
  27. package/src/baton.mjs +2 -2
  28. package/src/cli/auditor-context.mjs +141 -0
  29. package/src/cli/auditor-context.test.mjs +148 -0
  30. package/src/cli/codex-hook.mjs +20 -0
  31. package/src/codex-thread-index.mjs +11 -2
  32. package/src/db.mjs +2 -2
  33. package/src/hook-entrypoints.test.mjs +102 -0
  34. package/src/package-files.test.mjs +1 -0
  35. package/src/phase0-spotter-contract.test.mjs +280 -0
  36. package/src/prompt-submit.mjs +2 -2
  37. package/src/resume-context.mjs +1 -1
  38. package/src/session-merger.mjs +1 -1
  39. package/src/session-start.mjs +62 -3
  40. package/src/spike-transcript-writer.mjs +1 -1
  41. package/src/state-file.mjs +1 -1
  42. package/src/token-monitor.mjs +1 -1
  43. package/src/transcript-reader.mjs +71 -0
  44. package/src/turn-backfill.mjs +131 -0
  45. package/src/turn-backfill.test.mjs +213 -0
  46. package/src/turn-processor.mjs +28 -40
  47. /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
  48. /package/{docs/RAG/_raw/01-hooks → rag/01-hooks/raw}/hooks-reference-extract.md +0 -0
  49. /package/{docs/RAG/_raw/02-messages-api → rag/02-messages-api/raw}/messages-api-extract.md +0 -0
  50. /package/{docs/RAG/_raw/03-settings → rag/03-settings/raw}/sessions-extract.md +0 -0
  51. /package/{docs/RAG/_raw/04-skills → rag/04-skills/raw}/initialUserMessage-investigation.md +0 -0
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',
@@ -0,0 +1,280 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import test from 'node:test';
15
+ import { DatabaseSync } from 'node:sqlite';
16
+
17
+ import { captureCodexRolloutToDb } from './codex-capture.mjs';
18
+ import { runCodexUserPromptSubmitHook } from './cli/codex-hook.mjs';
19
+ import { findCodexThreadCandidate } from './codex-thread-index.mjs';
20
+
21
+ const THREAD_ID = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
22
+
23
+ function makeCaptureDb() {
24
+ const db = new DatabaseSync(':memory:');
25
+ db.exec(`
26
+ CREATE TABLE sessions (
27
+ session_id TEXT PRIMARY KEY,
28
+ project_path TEXT NOT NULL,
29
+ status TEXT NOT NULL DEFAULT 'active',
30
+ created_at INTEGER NOT NULL,
31
+ updated_at INTEGER NOT NULL,
32
+ merged_into TEXT
33
+ );
34
+ CREATE TABLE skeletons (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ session_id TEXT NOT NULL,
37
+ origin_session_id TEXT,
38
+ turn_number INTEGER NOT NULL,
39
+ role TEXT NOT NULL,
40
+ summary TEXT NOT NULL,
41
+ created_at INTEGER NOT NULL
42
+ );
43
+ CREATE TABLE bodies (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ session_id TEXT NOT NULL,
46
+ origin_session_id TEXT NOT NULL,
47
+ turn_number INTEGER NOT NULL,
48
+ role TEXT NOT NULL,
49
+ text TEXT NOT NULL,
50
+ token_count INTEGER,
51
+ created_at INTEGER NOT NULL
52
+ );
53
+ CREATE TABLE details (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ session_id TEXT NOT NULL,
56
+ origin_session_id TEXT,
57
+ turn_number INTEGER,
58
+ tool_name TEXT NOT NULL,
59
+ input_text TEXT,
60
+ output_text TEXT,
61
+ token_count INTEGER NOT NULL DEFAULT 0,
62
+ created_at INTEGER NOT NULL,
63
+ kind TEXT,
64
+ source_id TEXT
65
+ );
66
+ `);
67
+ return db;
68
+ }
69
+
70
+ function event(type, payload = {}) {
71
+ return {
72
+ timestamp: '2026-07-13T00:00:00.000Z',
73
+ type: 'event_msg',
74
+ payload: { type, ...payload },
75
+ };
76
+ }
77
+
78
+ function developerMemory(text = '## Throughline: Active Work Context\ninternal memory') {
79
+ return {
80
+ timestamp: '2026-07-13T00:00:01.000Z',
81
+ type: 'response_item',
82
+ payload: {
83
+ type: 'message',
84
+ role: 'developer',
85
+ content: [{ type: 'input_text', text }],
86
+ },
87
+ };
88
+ }
89
+
90
+ function toolInput() {
91
+ return {
92
+ timestamp: '2026-07-13T00:00:01.000Z',
93
+ type: 'response_item',
94
+ payload: {
95
+ type: 'function_call',
96
+ name: 'exec_command',
97
+ arguments: '{"cmd":"pwd"}',
98
+ call_id: 'call_inflight',
99
+ },
100
+ };
101
+ }
102
+
103
+ function writeRollout(home, { cwd, id = THREAD_ID, events = [] }) {
104
+ const dir = join(home, 'sessions', '2026', '07', '13');
105
+ mkdirSync(dir, { recursive: true });
106
+ const path = join(dir, `rollout-2026-07-13T00-00-00-${id}.jsonl`);
107
+ const rows = [
108
+ {
109
+ timestamp: '2026-07-13T00:00:00.000Z',
110
+ type: 'session_meta',
111
+ payload: { id, cwd, source: 'vscode', cli_version: '0.128.0-alpha.1' },
112
+ },
113
+ ...events,
114
+ ];
115
+ writeFileSync(path, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`);
116
+ return path;
117
+ }
118
+
119
+ test('Phase 0: capture projection preserves one completed user/assistant pair and its Codex origin identity', () => {
120
+ const home = mkdtempSync(join(tmpdir(), 'tl-phase0-capture-home-'));
121
+ const project = mkdtempSync(join(tmpdir(), 'tl-phase0-capture-project-'));
122
+ const db = makeCaptureDb();
123
+ try {
124
+ writeRollout(home, {
125
+ cwd: project,
126
+ events: [
127
+ event('user_message', { message: 'completed user request' }),
128
+ event('task_started'),
129
+ event('agent_message', { message: 'completed assistant response' }),
130
+ event('task_complete'),
131
+ developerMemory(),
132
+ ],
133
+ });
134
+
135
+ const result = captureCodexRolloutToDb(db, { threadId: THREAD_ID, codexHome: home, projectPath: project });
136
+ assert.equal(result.status, 'captured');
137
+ assert.deepEqual(
138
+ db
139
+ .prepare('SELECT origin_session_id, turn_number, role, text FROM bodies ORDER BY id')
140
+ .all()
141
+ .map((row) => ({ ...row })),
142
+ [
143
+ { origin_session_id: `codex:${THREAD_ID}`, turn_number: 1, role: 'user', text: 'completed user request' },
144
+ {
145
+ origin_session_id: `codex:${THREAD_ID}`,
146
+ turn_number: 1,
147
+ role: 'assistant',
148
+ text: 'completed assistant response',
149
+ },
150
+ ],
151
+ 'audit projection candidates are completed conversation pairs only',
152
+ );
153
+ } finally {
154
+ db.close();
155
+ rmSync(home, { recursive: true, force: true });
156
+ rmSync(project, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test('Phase 0: read-only WAL audit harness sees committed data during a writer transaction without writing DB sidecars', () => {
161
+ const dir = mkdtempSync(join(tmpdir(), 'tl-phase0-wal-'));
162
+ const path = join(dir, 'throughline.db');
163
+ const writer = new DatabaseSync(path);
164
+ let reader;
165
+ try {
166
+ writer.exec('PRAGMA journal_mode = WAL; CREATE TABLE audit_probe (value TEXT); INSERT INTO audit_probe VALUES (\'committed\');');
167
+ writer.exec("BEGIN IMMEDIATE; UPDATE audit_probe SET value = 'uncommitted';");
168
+
169
+ const before = snapshotSqliteFiles(path);
170
+ reader = new DatabaseSync(path, { readOnly: true });
171
+ assert.equal(reader.prepare('SELECT value FROM audit_probe').get().value, 'committed');
172
+ assert.throws(() => reader.exec("INSERT INTO audit_probe VALUES ('forbidden')"));
173
+ assert.deepEqual(snapshotSqliteFiles(path), before, 'audit reader must not modify DB, -wal, or -shm');
174
+ } finally {
175
+ reader?.close();
176
+ writer.exec('ROLLBACK');
177
+ writer.close();
178
+ rmSync(dir, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test('Phase 0: Spotter child environment prevents Throughline Codex hook re-entry before any capture side effect', async () => {
183
+ const home = mkdtempSync(join(tmpdir(), 'tl-phase0-spotter-home-'));
184
+ const project = mkdtempSync(join(tmpdir(), 'tl-phase0-spotter-project-'));
185
+ try {
186
+ writeRollout(home, {
187
+ cwd: project,
188
+ events: [
189
+ event('user_message', { message: 'must not be captured from Spotter child' }),
190
+ event('task_started'),
191
+ event('agent_message', { message: 'must not be captured from Spotter child' }),
192
+ event('task_complete'),
193
+ ],
194
+ });
195
+
196
+ for (const childEnv of ['SPOTTER_PARENT_PID', 'SPOTTER_BACKEND', 'SPOTTER_CHILD_BACKEND']) {
197
+ const db = makeCaptureDb();
198
+ let monitorWrites = 0;
199
+ let taskEnsures = 0;
200
+ try {
201
+ const result = await runCodexUserPromptSubmitHook({
202
+ args: { codexThreadId: THREAD_ID, codexHome: home, projectPath: project },
203
+ env: { [childEnv]: '1' },
204
+ db,
205
+ ensureMonitorTask: () => {
206
+ taskEnsures++;
207
+ },
208
+ writeMonitorState: () => {
209
+ monitorWrites++;
210
+ },
211
+ buildMonitorUsage: () => null,
212
+ });
213
+
214
+ assert.equal(result.status, 'skipped', childEnv);
215
+ assert.equal(result.reason, 'spotter_child_backend', childEnv);
216
+ assert.equal(taskEnsures, 0, childEnv);
217
+ assert.equal(monitorWrites, 0, childEnv);
218
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM bodies').get().count, 0, childEnv);
219
+ } finally {
220
+ db.close();
221
+ }
222
+ }
223
+ } finally {
224
+ rmSync(home, { recursive: true, force: true });
225
+ rmSync(project, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ test('Phase 0: project identity accepts a rollout under a marker root subdirectory through symlink and Windows-style case', () => {
230
+ const home = mkdtempSync(join(tmpdir(), 'tl-phase0-identity-home-'));
231
+ const markerRoot = mkdtempSync(join(tmpdir(), 'tl-phase0-marker-root-'));
232
+ const aliasParent = mkdtempSync(join(tmpdir(), 'tl-phase0-marker-alias-'));
233
+ const child = join(markerRoot, 'packages', 'adapter');
234
+ const alias = join(aliasParent, 'spotter-link');
235
+ try {
236
+ mkdirSync(child, { recursive: true });
237
+ symlinkSync(markerRoot, alias);
238
+ assert.ok(lstatSync(alias).isSymbolicLink());
239
+
240
+ writeRollout(home, { cwd: child });
241
+ assert.equal(
242
+ findCodexThreadCandidate({ threadId: THREAD_ID, codexHome: home, projectPath: alias })?.id,
243
+ THREAD_ID,
244
+ 'marker root must include rollout cwd descendants after symlink resolution',
245
+ );
246
+
247
+ const windowsThreadId = '019dfabb-1111-7111-8111-111111111111';
248
+ writeRollout(home, {
249
+ id: windowsThreadId,
250
+ cwd: 'C:\\Users\\Kite\\Developer\\Spotter\\packages\\adapter',
251
+ });
252
+ assert.equal(
253
+ findCodexThreadCandidate({
254
+ threadId: windowsThreadId,
255
+ codexHome: home,
256
+ projectPath: 'c:/users/kite/developer/spotter',
257
+ })?.id,
258
+ windowsThreadId,
259
+ 'Windows-style path case must retain marker-root descendant identity',
260
+ );
261
+ } finally {
262
+ rmSync(home, { recursive: true, force: true });
263
+ rmSync(markerRoot, { recursive: true, force: true });
264
+ rmSync(aliasParent, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ function snapshotSqliteFiles(path) {
269
+ return [path, `${path}-wal`, `${path}-shm`].map((file) => {
270
+ if (!existsSync(file)) return { file, exists: false };
271
+ const stat = lstatSync(file);
272
+ return {
273
+ file,
274
+ exists: true,
275
+ size: stat.size,
276
+ mtimeMs: stat.mtimeMs,
277
+ bytes: readFileSync(file).toString('hex'),
278
+ };
279
+ });
280
+ }
@@ -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)にしばしば含まれる色コードを剥がす。