throughline 0.4.11 → 0.5.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.
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import {
5
5
  existsSync,
6
+ mkdirSync,
6
7
  mkdtempSync,
8
+ readFileSync,
7
9
  rmSync,
8
10
  writeFileSync,
9
11
  } from 'node:fs';
@@ -367,3 +369,289 @@ test('process-turn subprocess stores L2 bodies and L3 details in an isolated DB'
367
369
  rmSync(home, { recursive: true, force: true });
368
370
  }
369
371
  });
372
+
373
+ // ---- Phase 0-5 spike (UserPromptSubmit) ----
374
+
375
+ function seedMergedSession(home, sessionId, originId = 'orig-sess') {
376
+ // DB を直接作って bodies に origin != session_id を入れ、spikeInject が
377
+ // recentBodies を返せる状態にする。
378
+ const dbPath = join(home, '.throughline', 'throughline.db');
379
+ mkdirSync(dirname(dbPath), { recursive: true });
380
+ const db = new DatabaseSync(dbPath);
381
+ db.exec(`
382
+ CREATE TABLE IF NOT EXISTS sessions (
383
+ session_id TEXT PRIMARY KEY,
384
+ project_path TEXT NOT NULL,
385
+ status TEXT NOT NULL DEFAULT 'active',
386
+ created_at INTEGER NOT NULL,
387
+ updated_at INTEGER NOT NULL,
388
+ merged_into TEXT
389
+ );
390
+ CREATE TABLE IF NOT EXISTS skeletons (
391
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
392
+ session_id TEXT NOT NULL,
393
+ origin_session_id TEXT,
394
+ turn_number INTEGER NOT NULL,
395
+ role TEXT NOT NULL,
396
+ summary TEXT NOT NULL,
397
+ created_at INTEGER NOT NULL
398
+ );
399
+ CREATE TABLE IF NOT EXISTS bodies (
400
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
401
+ session_id TEXT NOT NULL,
402
+ origin_session_id TEXT NOT NULL,
403
+ turn_number INTEGER NOT NULL,
404
+ role TEXT NOT NULL,
405
+ text TEXT NOT NULL,
406
+ token_count INTEGER,
407
+ created_at INTEGER NOT NULL
408
+ );
409
+ CREATE TABLE IF NOT EXISTS details (
410
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
411
+ session_id TEXT NOT NULL,
412
+ origin_session_id TEXT,
413
+ turn_number INTEGER,
414
+ tool_name TEXT NOT NULL,
415
+ input_text TEXT,
416
+ output_text TEXT,
417
+ token_count INTEGER NOT NULL DEFAULT 0,
418
+ created_at INTEGER NOT NULL,
419
+ kind TEXT,
420
+ source_id TEXT
421
+ );
422
+ CREATE TABLE IF NOT EXISTS handoff_batons (
423
+ project_path TEXT PRIMARY KEY,
424
+ session_id TEXT NOT NULL,
425
+ created_at INTEGER NOT NULL
426
+ );
427
+ `);
428
+ db.prepare(
429
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
430
+ VALUES (?, '/repo', 'active', 1, 2)`,
431
+ ).run(sessionId);
432
+ for (const r of [
433
+ { turn: 1, role: 'user', text: 'past user turn', createdAt: 1000 },
434
+ { turn: 2, role: 'assistant', text: 'past assistant turn', createdAt: 1100 },
435
+ ]) {
436
+ db.prepare(
437
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
438
+ VALUES (?, ?, ?, ?, ?, 1, ?)`,
439
+ ).run(sessionId, originId, r.turn, r.role, r.text, r.createdAt);
440
+ }
441
+ db.close();
442
+ }
443
+
444
+ function touchSpikePromptFlag(home) {
445
+ const flagPath = join(home, '.throughline', 'spike-prompt.flag');
446
+ mkdirSync(dirname(flagPath), { recursive: true });
447
+ writeFileSync(flagPath, '', 'utf8');
448
+ }
449
+
450
+ function readPromptSpikeLog(home) {
451
+ const path = join(home, '.throughline', 'logs', 'prompt-spike.log');
452
+ if (!existsSync(path)) return [];
453
+ return readFileSync(path, 'utf8')
454
+ .split('\n')
455
+ .filter((l) => l.trim())
456
+ .map((l) => JSON.parse(l));
457
+ }
458
+
459
+ test('Phase 0-5: spike SKIPS when marker file is absent', () => {
460
+ const home = makeTempHome();
461
+ const project = makeTempProject();
462
+ try {
463
+ seedMergedSession(home, 'sess-no-marker');
464
+ const transcriptPath = join(project, 'transcript.jsonl');
465
+ writeFileSync(transcriptPath, '', 'utf8');
466
+
467
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
468
+ home,
469
+ cwd: project,
470
+ input: JSON.stringify({
471
+ session_id: 'sess-no-marker',
472
+ cwd: project,
473
+ prompt: 'hello world',
474
+ transcript_path: transcriptPath,
475
+ }),
476
+ });
477
+ assert.equal(result.status, 0, result.stderr);
478
+ assert.equal(readFileSync(transcriptPath, 'utf8'), '', 'transcript untouched');
479
+ assert.equal(readPromptSpikeLog(home).length, 0, 'no spike log entry');
480
+ } finally {
481
+ rmSync(project, { recursive: true, force: true });
482
+ rmSync(home, { recursive: true, force: true });
483
+ }
484
+ });
485
+
486
+ test('Phase 0-5: spike LOGS skip_reason when transcript_path is missing in payload', () => {
487
+ const home = makeTempHome();
488
+ const project = makeTempProject();
489
+ try {
490
+ seedMergedSession(home, 'sess-no-tp');
491
+ touchSpikePromptFlag(home);
492
+
493
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
494
+ home,
495
+ cwd: project,
496
+ input: JSON.stringify({
497
+ session_id: 'sess-no-tp',
498
+ cwd: project,
499
+ prompt: 'hello world',
500
+ // transcript_path omitted
501
+ }),
502
+ });
503
+ assert.equal(result.status, 0, result.stderr);
504
+ const logs = readPromptSpikeLog(home);
505
+ assert.equal(logs.length, 1);
506
+ assert.equal(logs[0].skip_reason, 'no_transcript_path_in_payload');
507
+ } finally {
508
+ rmSync(project, { recursive: true, force: true });
509
+ rmSync(home, { recursive: true, force: true });
510
+ }
511
+ });
512
+
513
+ test('Phase 0-5: spike INJECTS into JSONL chain-reachable from last uuid on first prompt', () => {
514
+ const home = makeTempHome();
515
+ const project = makeTempProject();
516
+ try {
517
+ seedMergedSession(home, 'sess-inject');
518
+ touchSpikePromptFlag(home);
519
+
520
+ // Pre-populate transcript with 1 attachment line (= simulates SessionStart hook output)
521
+ const transcriptPath = join(project, 'transcript.jsonl');
522
+ const attachmentLine = JSON.stringify({
523
+ type: 'attachment',
524
+ uuid: 'pre-attach-uuid',
525
+ parentUuid: null,
526
+ });
527
+ writeFileSync(transcriptPath, attachmentLine + '\n', 'utf8');
528
+
529
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
530
+ home,
531
+ cwd: project,
532
+ input: JSON.stringify({
533
+ session_id: 'sess-inject',
534
+ cwd: project,
535
+ prompt: 'tell me the spike-tracer',
536
+ transcript_path: transcriptPath,
537
+ version: '2.1.145',
538
+ gitBranch: 'main',
539
+ }),
540
+ });
541
+ assert.equal(result.status, 0, result.stderr);
542
+
543
+ const content = readFileSync(transcriptPath, 'utf8');
544
+ const lines = content.split('\n').filter((l) => l.trim());
545
+ assert.equal(lines.length, 3, '1 preexisting + 2 spike lines');
546
+ const firstSpike = JSON.parse(lines[1]);
547
+ assert.equal(firstSpike.parentUuid, 'pre-attach-uuid', 'chain (b) from last attachment uuid');
548
+ const lastSpike = JSON.parse(lines[2]);
549
+ assert.match(
550
+ lastSpike.message.content[0].text,
551
+ /\[spike-tracer: [0-9a-f]{8}\]$/,
552
+ 'tracer in last assistant',
553
+ );
554
+
555
+ const logs = readPromptSpikeLog(home);
556
+ assert.equal(logs.length, 1);
557
+ assert.equal(logs[0].parent_uuid_start, 'pre-attach-uuid');
558
+ assert.equal(logs[0].appended, 2);
559
+ assert.match(logs[0].tracer, /^[0-9a-f]{8}$/);
560
+
561
+ // per-session marker created
562
+ assert.ok(
563
+ existsSync(join(home, '.throughline', 'spike-prompt-state', 'sess-inject')),
564
+ 'per-session marker created after successful spike',
565
+ );
566
+ } finally {
567
+ rmSync(project, { recursive: true, force: true });
568
+ rmSync(home, { recursive: true, force: true });
569
+ }
570
+ });
571
+
572
+ test('Phase 0-5: spike is IDEMPOTENT per session (second call is no-op)', () => {
573
+ const home = makeTempHome();
574
+ const project = makeTempProject();
575
+ try {
576
+ seedMergedSession(home, 'sess-idem');
577
+ touchSpikePromptFlag(home);
578
+
579
+ const transcriptPath = join(project, 'transcript.jsonl');
580
+ writeFileSync(
581
+ transcriptPath,
582
+ JSON.stringify({ type: 'attachment', uuid: 'preU', parentUuid: null }) + '\n',
583
+ 'utf8',
584
+ );
585
+
586
+ const firstPayload = JSON.stringify({
587
+ session_id: 'sess-idem',
588
+ cwd: project,
589
+ prompt: 'first',
590
+ transcript_path: transcriptPath,
591
+ });
592
+ const secondPayload = JSON.stringify({
593
+ session_id: 'sess-idem',
594
+ cwd: project,
595
+ prompt: 'second',
596
+ transcript_path: transcriptPath,
597
+ });
598
+
599
+ const r1 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
600
+ home,
601
+ cwd: project,
602
+ input: firstPayload,
603
+ });
604
+ assert.equal(r1.status, 0, r1.stderr);
605
+ const after1 = readFileSync(transcriptPath, 'utf8').split('\n').filter((l) => l.trim()).length;
606
+
607
+ const r2 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
608
+ home,
609
+ cwd: project,
610
+ input: secondPayload,
611
+ });
612
+ assert.equal(r2.status, 0, r2.stderr);
613
+ const after2 = readFileSync(transcriptPath, 'utf8').split('\n').filter((l) => l.trim()).length;
614
+
615
+ assert.equal(after2, after1, 'second prompt did not append additional lines');
616
+ assert.equal(readPromptSpikeLog(home).length, 1, 'only the first call logged');
617
+ } finally {
618
+ rmSync(project, { recursive: true, force: true });
619
+ rmSync(home, { recursive: true, force: true });
620
+ }
621
+ });
622
+
623
+ test('Phase 0-5: /tl and /clear prompts do NOT trigger spike', () => {
624
+ const home = makeTempHome();
625
+ const project = makeTempProject();
626
+ try {
627
+ seedMergedSession(home, 'sess-slash');
628
+ touchSpikePromptFlag(home);
629
+ const transcriptPath = join(project, 'transcript.jsonl');
630
+ writeFileSync(
631
+ transcriptPath,
632
+ JSON.stringify({ type: 'attachment', uuid: 'preU', parentUuid: null }) + '\n',
633
+ 'utf8',
634
+ );
635
+
636
+ const r = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
637
+ home,
638
+ cwd: project,
639
+ input: JSON.stringify({
640
+ session_id: 'sess-slash',
641
+ cwd: project,
642
+ prompt: '/tl',
643
+ transcript_path: transcriptPath,
644
+ }),
645
+ });
646
+ assert.equal(r.status, 0, r.stderr);
647
+
648
+ assert.equal(readPromptSpikeLog(home).length, 0, 'no spike log for /tl');
649
+ assert.ok(
650
+ !existsSync(join(home, '.throughline', 'spike-prompt-state', 'sess-slash')),
651
+ 'per-session marker NOT created for /tl',
652
+ );
653
+ } finally {
654
+ rmSync(project, { recursive: true, force: true });
655
+ rmSync(home, { recursive: true, force: true });
656
+ }
657
+ });
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * UserPromptSubmit hook — /tl & /clear スラッシュコマンド検出 + バトン書き込み
3
+ * UserPromptSubmit hook — /tl & /clear スラッシュコマンド検出 + バトン書き込み + Phase 0-5 spike
4
4
  *
5
- * stdin: { session_id, cwd, prompt, hook_event_name, ... }
5
+ * stdin: { session_id, cwd, prompt, transcript_path, hook_event_name, ... }
6
6
  *
7
7
  * 動作:
8
8
  * - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
@@ -13,7 +13,14 @@
13
13
  * VSCode ウィンドウ等で「最新更新セッション = clear されたセッション」が
14
14
  * 成立しない multi-window シナリオで誤った前任を選ばないための確定的指名)
15
15
  * - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
16
- * - 本 hook は注入を一切行わない (SessionStart の引き継ぎ注入と二重にならないため)
16
+ * - 本 hook は引き継ぎ注入を行わない (SessionStart の stdout 注入と二重にならないため)
17
+ *
18
+ * - **Phase 0-5 spike (SPIKE ONLY)**: marker file `~/.throughline/spike-prompt.flag`
19
+ * が存在し、当該セッションでまだ spike を打っていなければ、JSONL に user/assistant
20
+ * 行を chain-reachable (= 直前の attachment uuid を parent に取る) で append する。
21
+ * SessionStart 経路の spike (chain (a) = orphan) ではモデルに届かなかったため、
22
+ * UserPromptSubmit 経路で chain (b) を成立させて再検証する。
23
+ * docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-5 参照。
17
24
  *
18
25
  * 設計背景: docs/INHERITANCE_ON_CLEAR_ONLY.md バトン方式
19
26
  */
@@ -21,11 +28,15 @@
21
28
  import { getDb } from './db.mjs';
22
29
  import { writeBaton } from './baton.mjs';
23
30
  import { ensureMonitorTaskFile } from './vscode-task.mjs';
24
- import { appendFileSync, mkdirSync } from 'node:fs';
31
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
32
  import { join, dirname } from 'node:path';
26
33
  import { homedir } from 'node:os';
27
34
  import { pathToFileURL } from 'node:url';
28
35
 
36
+ // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
37
+ const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
38
+ const PROMPT_SPIKE_STATE_DIR = join(homedir(), '.throughline', 'spike-prompt-state');
39
+
29
40
  function logBaton(entry) {
30
41
  const path = join(homedir(), '.throughline', 'logs', 'baton-write.log');
31
42
  try {
@@ -37,6 +48,56 @@ function logBaton(entry) {
37
48
  }
38
49
  }
39
50
 
51
+ function logPromptSpike(entry) {
52
+ const path = join(homedir(), '.throughline', 'logs', 'prompt-spike.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(`[prompt-submit:spike-log] ${msg}\n`);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * transcript_path の末尾 3 行を読み、各行の type / uuid / parentUuid を返す。
64
+ * 診断ログ用 (chain (b) が CC に効いたかを後で検証するための context)。
65
+ */
66
+ function readTailLineSummary(targetJsonlPath, n = 3) {
67
+ if (!existsSync(targetJsonlPath)) return null;
68
+ try {
69
+ const text = readFileSync(targetJsonlPath, 'utf8');
70
+ const lines = text.split('\n').filter((l) => l.trim());
71
+ const tail = lines.slice(-n);
72
+ return tail.map((l) => {
73
+ try {
74
+ const o = JSON.parse(l);
75
+ const sub = o.type === 'attachment'
76
+ ? `attachment.${o.attachment?.type ?? '?'}`
77
+ : (o.type ?? 'n/a');
78
+ return { type: sub, uuid: o.uuid ?? null, parentUuid: o.parentUuid ?? null };
79
+ } catch {
80
+ return { type: 'parse-error', uuid: null, parentUuid: null };
81
+ }
82
+ });
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Phase 0-5 spike を当該セッションで既に打ったかを判定する。
90
+ * per-session marker file `~/.throughline/spike-prompt-state/<session_id>` の存在で判定。
91
+ */
92
+ function alreadySpiked(sessionId) {
93
+ return existsSync(join(PROMPT_SPIKE_STATE_DIR, sessionId));
94
+ }
95
+
96
+ function markSpiked(sessionId) {
97
+ mkdirSync(PROMPT_SPIKE_STATE_DIR, { recursive: true });
98
+ writeFileSync(join(PROMPT_SPIKE_STATE_DIR, sessionId), '', 'utf8');
99
+ }
100
+
40
101
  /**
41
102
  * プロンプトが /tl バトン発動コマンドか判定する。
42
103
  * 許容: "/tl", "/tl\n", "/tl 何か" (前後空白は trim 済み前提)
@@ -87,6 +148,13 @@ export async function run() {
87
148
  const tlMatch = isBatonCommand(prompt);
88
149
  const clearMatch = !tlMatch && isClearCommand(prompt);
89
150
 
151
+ // Phase 0-5 spike: real user prompt (not /tl, not /clear) で、marker file あり、
152
+ // session 未 spike なら chain (b) で JSONL に inject する。失敗しても prompt 自体は
153
+ // そのまま Claude に流す。
154
+ if (!tlMatch && !clearMatch && session_id && existsSync(PROMPT_SPIKE_MARKER_PATH)) {
155
+ await maybeRunPromptSpike({ payload, sessionId: session_id, projectPath: cwd ?? process.cwd() });
156
+ }
157
+
90
158
  if (!tlMatch && !clearMatch) {
91
159
  process.exit(0);
92
160
  return;
@@ -114,6 +182,65 @@ export async function run() {
114
182
  process.exit(0);
115
183
  }
116
184
 
185
+ /**
186
+ * Phase 0-5 spike を本セッションでまだ打っていなければ inject する。
187
+ * idempotency: per-session marker `~/.throughline/spike-prompt-state/<session_id>`。
188
+ *
189
+ * 失敗は stderr に出して continue。本 hook の主目的 (baton / tasks.json) を阻害しない。
190
+ */
191
+ async function maybeRunPromptSpike({ payload, sessionId, projectPath }) {
192
+ const transcriptPath = payload.transcript_path ?? null;
193
+ if (!transcriptPath) {
194
+ logPromptSpike({
195
+ ts: new Date().toISOString(),
196
+ session_id: sessionId,
197
+ skip_reason: 'no_transcript_path_in_payload',
198
+ });
199
+ return;
200
+ }
201
+ if (alreadySpiked(sessionId)) {
202
+ return; // 静かに skip (1 session 1 回限定)
203
+ }
204
+ try {
205
+ const { spikeInject, generateSpikeTracer } = await import('./spike-transcript-writer.mjs');
206
+ const { getDb } = await import('./db.mjs');
207
+ const db = getDb();
208
+ const tracer = generateSpikeTracer();
209
+ const tailBefore = readTailLineSummary(transcriptPath);
210
+ const result = spikeInject({
211
+ db,
212
+ targetJsonlPath: transcriptPath,
213
+ newSessionId: sessionId,
214
+ cwd: projectPath,
215
+ version: payload.version ?? '2.1.145',
216
+ gitBranch: payload.gitBranch ?? 'main',
217
+ tracer,
218
+ });
219
+ logPromptSpike({
220
+ ts: new Date().toISOString(),
221
+ session_id: sessionId,
222
+ transcript_path: transcriptPath,
223
+ tracer: result.tracer,
224
+ parent_uuid_start: result.parentUuidStart,
225
+ appended: result.appended,
226
+ tracer_appended_at: result.tracerAppendedAt,
227
+ skip_reason: result.skipReason ?? null,
228
+ tail_before: tailBefore,
229
+ });
230
+ if (result.appended > 0) {
231
+ markSpiked(sessionId);
232
+ }
233
+ } catch (err) {
234
+ const msg = err instanceof Error ? err.message : 'unknown';
235
+ process.stderr.write(`[prompt-spike] ${msg}\n`);
236
+ logPromptSpike({
237
+ ts: new Date().toISOString(),
238
+ session_id: sessionId,
239
+ error: msg,
240
+ });
241
+ }
242
+ }
243
+
117
244
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
118
245
  run().catch((err) => {
119
246
  const msg = err instanceof Error ? err.message : 'unknown';
@@ -5,8 +5,11 @@
5
5
  * - session-start.mjs (auto path / baton path どちらでも同じ注入)
6
6
  *
7
7
  * 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
8
- * - 注入順: ヘッダ + 読み方 → L1 要約 → L2 本文(一番下)
9
- * - 直前の発話に Claude attention が向くよう、L2 を末尾に置く
8
+ * - 注入順: ヘッダ + 読み方 → 現在地アンカー → L1 要約 → L2 本文(一番下)
9
+ * - 「現在地」アンカーは直前の user / assistant turn をヘッダ直下に再掲して
10
+ * 最初の注意を最新ターンに固定する。L2 末尾アンカーは補強として残す。
11
+ * (L2 が長くなると末尾アンカーだけでは前半の古いターンに注意が固着し、
12
+ * 話の流れを取り違える事例があった)
10
13
  * - L3 は別セクションを設けず、対応する L1 / L2 行にインラインで
11
14
  * `[→ throughline detail HH:MM:SS (kind …)]` ヒントを付ける
12
15
  * - L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が
@@ -22,15 +25,63 @@ import { buildHandoffRecord } from './handoff-record.mjs';
22
25
  import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
23
26
 
24
27
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
25
- `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
28
+ `## Throughline: 直前スレッドの継続応答用コンテキスト(${turnCount} ターン分の文脈を保持)\n` +
26
29
  `\n` +
27
- `**読み方:**\n` +
28
- `- 直前の対話の自然な続きとして応答してください。\n` +
29
- '- **各ターンの詳細の取得方法**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
30
+ `**応答の冒頭で必ず以下を 1 行宣言してください** (会話の継続性をユーザーに見せるため):\n` +
31
+ `> 「Throughline で前のセッションから ${turnCount} ターン分の記憶を引き継いだ状態で続けます」\n` +
32
+ `\n` +
33
+ `**読み方 (重要):**\n` +
34
+ `- 下記「現在地」「L1」「L2」は **あなた自身が直前にユーザーと交わした会話** です ` +
35
+ `(画面が /clear されただけで、同じスレッドが継続しています)。\n` +
36
+ `- ユーザーの次のメッセージは新規依頼ではなく、上記スレッドの **続き** です。\n` +
37
+ `- 「続きよろしく」「OK」「次は?」のような短いメッセージは、**「現在地」の「直前のアシスタント」` +
38
+ `が示した次の一手をそのまま実行** してください。\n` +
39
+ `- **短文/相槌の判定**: 50 字以下、または「はい」「うん」「了解」「OK」「やって」「進めて」「続き」` +
40
+ `「次」のような相槌/同意/促し語だけで構成されているメッセージは、新しい依頼ではなく ` +
41
+ `**直前のアシスタントの提案に対する GO サイン** として扱ってください。\n` +
42
+ `- 短文を受けたら **やってはいけないこと**: 「何の話ですか?」と聞き返す / 改めて選択肢を提示しなおす / ` +
43
+ `直前のアシスタントが示した次の一手を無視して別の作業を始める。\n` +
44
+ `- **古い番号リストの再実行禁止**: 過去ターンで提示した番号付き選択肢 (例: 1/2/3) を最新ユーザーが ` +
45
+ `「2 をやれ」のように参照した場合、その項目が既に直前アシスタントターンで実装/実行済みなら、` +
46
+ `再実行ではなく **結果確認・次の一手** として応答してください ` +
47
+ `(最新アシスタント発話の指示が、過去ターンのリストへの参照より上位)。\n` +
48
+ `- 「何のことですか?」「初めまして」「何を確認したいですか?」と返すのは適切ではありません ` +
49
+ `(新規会話ではない)。\n` +
50
+ '- **各ターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
30
51
  `(該当ターンの本文+詳細を stdout に返します)`;
31
52
 
32
53
  const NORMAL_HEADER = '## Throughline: セッション記憶';
33
54
 
55
+ // 現在地アンカーは最新 user / assistant 本文を再掲する。長すぎると注入全体が膨らむので
56
+ // この文字数で打ち切り、全文は L2 セクション側を参照させる。
57
+ const ANCHOR_MAX_CHARS = 600;
58
+
59
+ function truncateForAnchor(text) {
60
+ const normalized = text.replace(/\n+/g, ' ').trim();
61
+ if (normalized.length <= ANCHOR_MAX_CHARS) return normalized;
62
+ return normalized.slice(0, ANCHOR_MAX_CHARS) + ' …';
63
+ }
64
+
65
+ /**
66
+ * recentBodies (古い順) から「直前のやりとり」アンカー用に
67
+ * 最新の user / assistant 行をそれぞれ 1 件ずつ拾う。
68
+ */
69
+ function pickLatestExchange(recentBodies) {
70
+ let latestUser = null;
71
+ let latestAssistant = null;
72
+ for (let i = recentBodies.length - 1; i >= 0; i -= 1) {
73
+ const r = recentBodies[i];
74
+ if (!r.text) continue;
75
+ if (r.role === 'assistant' && !latestAssistant) {
76
+ latestAssistant = r;
77
+ } else if (r.role === 'user' && !latestUser) {
78
+ latestUser = r;
79
+ }
80
+ if (latestUser && latestAssistant) break;
81
+ }
82
+ return { latestUser, latestAssistant };
83
+ }
84
+
34
85
  /**
35
86
  * L1 + L2 注入テキストを組み立てる。L3 は本文ではなく
36
87
  * 各 L1 / L2 行末尾の inline hint として付与する。
@@ -61,6 +112,28 @@ export function buildResumeContext(
61
112
 
62
113
  const l3ByTurn = groupL3ByTurn(record.references.l3);
63
114
 
115
+ // 現在地アンカー: 引き継ぎ時のみ、最新 user / assistant turn をヘッダ直下に再掲する。
116
+ // L2 末尾アンカーだけだと、長い L2 で注意が前半に固着して話の流れを取り違える事例があった。
117
+ if (isInheritance && record.memory.recentBodies.length > 0) {
118
+ const { latestUser, latestAssistant } = pickLatestExchange(record.memory.recentBodies);
119
+ const anchorLines = [];
120
+ if (latestUser) {
121
+ anchorLines.push(
122
+ `**最新ユーザー指示** [${latestUser.time}]: ${truncateForAnchor(latestUser.text)}`,
123
+ );
124
+ }
125
+ if (latestAssistant) {
126
+ anchorLines.push(
127
+ `**直前のアシスタント** [${latestAssistant.time}]: ${truncateForAnchor(latestAssistant.text)}`,
128
+ );
129
+ }
130
+ if (anchorLines.length > 0) {
131
+ lines.push('');
132
+ lines.push('### 現在地 (直前のやりとり)');
133
+ lines.push(...anchorLines);
134
+ }
135
+ }
136
+
64
137
  if (record.memory.l1Summaries.length > 0) {
65
138
  const l1Lines = [];
66
139
  for (const r of record.memory.l1Summaries) {