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
@@ -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,391 @@ 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
+ 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
+
475
+ // ---- Phase 0-5 spike (UserPromptSubmit) ----
476
+
477
+ function seedMergedSession(home, sessionId, originId = 'orig-sess') {
478
+ // DB を直接作って bodies に origin != session_id を入れ、spikeInject が
479
+ // recentBodies を返せる状態にする。
480
+ const dbPath = join(home, '.throughline', 'throughline.db');
481
+ mkdirSync(dirname(dbPath), { recursive: true });
482
+ const db = new DatabaseSync(dbPath);
483
+ db.exec(`
484
+ CREATE TABLE IF NOT EXISTS sessions (
485
+ session_id TEXT PRIMARY KEY,
486
+ project_path TEXT NOT NULL,
487
+ status TEXT NOT NULL DEFAULT 'active',
488
+ created_at INTEGER NOT NULL,
489
+ updated_at INTEGER NOT NULL,
490
+ merged_into TEXT
491
+ );
492
+ CREATE TABLE IF NOT EXISTS skeletons (
493
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
494
+ session_id TEXT NOT NULL,
495
+ origin_session_id TEXT,
496
+ turn_number INTEGER NOT NULL,
497
+ role TEXT NOT NULL,
498
+ summary TEXT NOT NULL,
499
+ created_at INTEGER NOT NULL
500
+ );
501
+ CREATE TABLE IF NOT EXISTS bodies (
502
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
503
+ session_id TEXT NOT NULL,
504
+ origin_session_id TEXT NOT NULL,
505
+ turn_number INTEGER NOT NULL,
506
+ role TEXT NOT NULL,
507
+ text TEXT NOT NULL,
508
+ token_count INTEGER,
509
+ created_at INTEGER NOT NULL
510
+ );
511
+ CREATE TABLE IF NOT EXISTS details (
512
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
513
+ session_id TEXT NOT NULL,
514
+ origin_session_id TEXT,
515
+ turn_number INTEGER,
516
+ tool_name TEXT NOT NULL,
517
+ input_text TEXT,
518
+ output_text TEXT,
519
+ token_count INTEGER NOT NULL DEFAULT 0,
520
+ created_at INTEGER NOT NULL,
521
+ kind TEXT,
522
+ source_id TEXT
523
+ );
524
+ CREATE TABLE IF NOT EXISTS handoff_batons (
525
+ project_path TEXT PRIMARY KEY,
526
+ session_id TEXT NOT NULL,
527
+ created_at INTEGER NOT NULL
528
+ );
529
+ `);
530
+ db.prepare(
531
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
532
+ VALUES (?, '/repo', 'active', 1, 2)`,
533
+ ).run(sessionId);
534
+ for (const r of [
535
+ { turn: 1, role: 'user', text: 'past user turn', createdAt: 1000 },
536
+ { turn: 2, role: 'assistant', text: 'past assistant turn', createdAt: 1100 },
537
+ ]) {
538
+ db.prepare(
539
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
540
+ VALUES (?, ?, ?, ?, ?, 1, ?)`,
541
+ ).run(sessionId, originId, r.turn, r.role, r.text, r.createdAt);
542
+ }
543
+ db.close();
544
+ }
545
+
546
+ function touchSpikePromptFlag(home) {
547
+ const flagPath = join(home, '.throughline', 'spike-prompt.flag');
548
+ mkdirSync(dirname(flagPath), { recursive: true });
549
+ writeFileSync(flagPath, '', 'utf8');
550
+ }
551
+
552
+ function readPromptSpikeLog(home) {
553
+ const path = join(home, '.throughline', 'logs', 'prompt-spike.log');
554
+ if (!existsSync(path)) return [];
555
+ return readFileSync(path, 'utf8')
556
+ .split('\n')
557
+ .filter((l) => l.trim())
558
+ .map((l) => JSON.parse(l));
559
+ }
560
+
561
+ test('Phase 0-5: spike SKIPS when marker file is absent', () => {
562
+ const home = makeTempHome();
563
+ const project = makeTempProject();
564
+ try {
565
+ seedMergedSession(home, 'sess-no-marker');
566
+ const transcriptPath = join(project, 'transcript.jsonl');
567
+ writeFileSync(transcriptPath, '', 'utf8');
568
+
569
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
570
+ home,
571
+ cwd: project,
572
+ input: JSON.stringify({
573
+ session_id: 'sess-no-marker',
574
+ cwd: project,
575
+ prompt: 'hello world',
576
+ transcript_path: transcriptPath,
577
+ }),
578
+ });
579
+ assert.equal(result.status, 0, result.stderr);
580
+ assert.equal(readFileSync(transcriptPath, 'utf8'), '', 'transcript untouched');
581
+ assert.equal(readPromptSpikeLog(home).length, 0, 'no spike log entry');
582
+ } finally {
583
+ rmSync(project, { recursive: true, force: true });
584
+ rmSync(home, { recursive: true, force: true });
585
+ }
586
+ });
587
+
588
+ test('Phase 0-5: spike LOGS skip_reason when transcript_path is missing in payload', () => {
589
+ const home = makeTempHome();
590
+ const project = makeTempProject();
591
+ try {
592
+ seedMergedSession(home, 'sess-no-tp');
593
+ touchSpikePromptFlag(home);
594
+
595
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
596
+ home,
597
+ cwd: project,
598
+ input: JSON.stringify({
599
+ session_id: 'sess-no-tp',
600
+ cwd: project,
601
+ prompt: 'hello world',
602
+ // transcript_path omitted
603
+ }),
604
+ });
605
+ assert.equal(result.status, 0, result.stderr);
606
+ const logs = readPromptSpikeLog(home);
607
+ assert.equal(logs.length, 1);
608
+ assert.equal(logs[0].skip_reason, 'no_transcript_path_in_payload');
609
+ } finally {
610
+ rmSync(project, { recursive: true, force: true });
611
+ rmSync(home, { recursive: true, force: true });
612
+ }
613
+ });
614
+
615
+ test('Phase 0-5: spike INJECTS into JSONL chain-reachable from last uuid on first prompt', () => {
616
+ const home = makeTempHome();
617
+ const project = makeTempProject();
618
+ try {
619
+ seedMergedSession(home, 'sess-inject');
620
+ touchSpikePromptFlag(home);
621
+
622
+ // Pre-populate transcript with 1 attachment line (= simulates SessionStart hook output)
623
+ const transcriptPath = join(project, 'transcript.jsonl');
624
+ const attachmentLine = JSON.stringify({
625
+ type: 'attachment',
626
+ uuid: 'pre-attach-uuid',
627
+ parentUuid: null,
628
+ });
629
+ writeFileSync(transcriptPath, attachmentLine + '\n', 'utf8');
630
+
631
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
632
+ home,
633
+ cwd: project,
634
+ input: JSON.stringify({
635
+ session_id: 'sess-inject',
636
+ cwd: project,
637
+ prompt: 'tell me the spike-tracer',
638
+ transcript_path: transcriptPath,
639
+ version: '2.1.145',
640
+ gitBranch: 'main',
641
+ }),
642
+ });
643
+ assert.equal(result.status, 0, result.stderr);
644
+
645
+ const content = readFileSync(transcriptPath, 'utf8');
646
+ const lines = content.split('\n').filter((l) => l.trim());
647
+ assert.equal(lines.length, 3, '1 preexisting + 2 spike lines');
648
+ const firstSpike = JSON.parse(lines[1]);
649
+ assert.equal(firstSpike.parentUuid, 'pre-attach-uuid', 'chain (b) from last attachment uuid');
650
+ const lastSpike = JSON.parse(lines[2]);
651
+ assert.match(
652
+ lastSpike.message.content[0].text,
653
+ /\[spike-tracer: [0-9a-f]{8}\]$/,
654
+ 'tracer in last assistant',
655
+ );
656
+
657
+ const logs = readPromptSpikeLog(home);
658
+ assert.equal(logs.length, 1);
659
+ assert.equal(logs[0].parent_uuid_start, 'pre-attach-uuid');
660
+ assert.equal(logs[0].appended, 2);
661
+ assert.match(logs[0].tracer, /^[0-9a-f]{8}$/);
662
+
663
+ // per-session marker created
664
+ assert.ok(
665
+ existsSync(join(home, '.throughline', 'spike-prompt-state', 'sess-inject')),
666
+ 'per-session marker created after successful spike',
667
+ );
668
+ } finally {
669
+ rmSync(project, { recursive: true, force: true });
670
+ rmSync(home, { recursive: true, force: true });
671
+ }
672
+ });
673
+
674
+ test('Phase 0-5: spike is IDEMPOTENT per session (second call is no-op)', () => {
675
+ const home = makeTempHome();
676
+ const project = makeTempProject();
677
+ try {
678
+ seedMergedSession(home, 'sess-idem');
679
+ touchSpikePromptFlag(home);
680
+
681
+ const transcriptPath = join(project, 'transcript.jsonl');
682
+ writeFileSync(
683
+ transcriptPath,
684
+ JSON.stringify({ type: 'attachment', uuid: 'preU', parentUuid: null }) + '\n',
685
+ 'utf8',
686
+ );
687
+
688
+ const firstPayload = JSON.stringify({
689
+ session_id: 'sess-idem',
690
+ cwd: project,
691
+ prompt: 'first',
692
+ transcript_path: transcriptPath,
693
+ });
694
+ const secondPayload = JSON.stringify({
695
+ session_id: 'sess-idem',
696
+ cwd: project,
697
+ prompt: 'second',
698
+ transcript_path: transcriptPath,
699
+ });
700
+
701
+ const r1 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
702
+ home,
703
+ cwd: project,
704
+ input: firstPayload,
705
+ });
706
+ assert.equal(r1.status, 0, r1.stderr);
707
+ const after1 = readFileSync(transcriptPath, 'utf8').split('\n').filter((l) => l.trim()).length;
708
+
709
+ const r2 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
710
+ home,
711
+ cwd: project,
712
+ input: secondPayload,
713
+ });
714
+ assert.equal(r2.status, 0, r2.stderr);
715
+ const after2 = readFileSync(transcriptPath, 'utf8').split('\n').filter((l) => l.trim()).length;
716
+
717
+ assert.equal(after2, after1, 'second prompt did not append additional lines');
718
+ assert.equal(readPromptSpikeLog(home).length, 1, 'only the first call logged');
719
+ } finally {
720
+ rmSync(project, { recursive: true, force: true });
721
+ rmSync(home, { recursive: true, force: true });
722
+ }
723
+ });
724
+
725
+ test('Phase 0-5: /tl and /clear prompts do NOT trigger spike', () => {
726
+ const home = makeTempHome();
727
+ const project = makeTempProject();
728
+ try {
729
+ seedMergedSession(home, 'sess-slash');
730
+ touchSpikePromptFlag(home);
731
+ const transcriptPath = join(project, 'transcript.jsonl');
732
+ writeFileSync(
733
+ transcriptPath,
734
+ JSON.stringify({ type: 'attachment', uuid: 'preU', parentUuid: null }) + '\n',
735
+ 'utf8',
736
+ );
737
+
738
+ const r = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
739
+ home,
740
+ cwd: project,
741
+ input: JSON.stringify({
742
+ session_id: 'sess-slash',
743
+ cwd: project,
744
+ prompt: '/tl',
745
+ transcript_path: transcriptPath,
746
+ }),
747
+ });
748
+ assert.equal(r.status, 0, r.stderr);
749
+
750
+ assert.equal(readPromptSpikeLog(home).length, 0, 'no spike log for /tl');
751
+ assert.ok(
752
+ !existsSync(join(home, '.throughline', 'spike-prompt-state', 'sess-slash')),
753
+ 'per-session marker NOT created for /tl',
754
+ );
755
+ } finally {
756
+ rmSync(project, { recursive: true, force: true });
757
+ rmSync(home, { recursive: true, force: true });
758
+ }
759
+ });
@@ -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',
@@ -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,19 +13,30 @@
13
13
  * VSCode ウィンドウ等で「最新更新セッション = clear されたセッション」が
14
14
  * 成立しない multi-window シナリオで誤った前任を選ばないための確定的指名)
15
15
  * - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
16
- * - 本 hook は注入を一切行わない (SessionStart の引き継ぎ注入と二重にならないため)
16
+ * - 本 hook は引き継ぎ注入を行わない (SessionStart の stdout 注入と二重にならないため)
17
17
  *
18
- * 設計背景: docs/INHERITANCE_ON_CLEAR_ONLY.md バトン方式
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/10_transcript_injection_plan.md Phase 0-5 参照。
24
+ *
25
+ * 設計背景: docs/03_inheritance_on_clear_only.md バトン方式
19
26
  */
20
27
 
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';
@@ -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 末尾アンカーは補強として残す。
@@ -25,12 +25,29 @@ import { buildHandoffRecord } from './handoff-record.mjs';
25
25
  import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
26
26
 
27
27
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
28
- `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
28
+ `## Throughline: 直前スレッドの継続応答用コンテキスト(${turnCount} ターン分の文脈を保持)\n` +
29
29
  `\n` +
30
- `**読み方:**\n` +
31
- `- **下の「現在地」が直前のやりとりです。まずここで最新の状態と次の一手を把握してから L1/L2 を読んでください。**\n` +
32
- `- 直前の対話の自然な続きとして応答してください。\n` +
33
- '- **各ターンの詳細の取得方法**: **`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` を実行** ' +
34
51
  `(該当ターンの本文+詳細を stdout に返します)`;
35
52
 
36
53
  const NORMAL_HEADER = '## Throughline: セッション記憶';
@@ -93,7 +93,9 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
93
93
  });
94
94
 
95
95
  assert.ok(text);
96
- assert.match(text, /^## Throughline: 中断した作業の再開/);
96
+ // A 経路: 「直前スレッドの継続応答用コンテキスト」 framing (元の「中断した作業の再開」よりも
97
+ // 強い directive。モデルが /clear 後の短い prompt を新規依頼として扱うのを抑止する目的)
98
+ assert.match(text, /^## Throughline: 直前スレッドの継続応答用コンテキスト/);
97
99
 
98
100
  // 旧版の冗長な行は全部削除
99
101
  assert.ok(!text.includes('と報告してください'), 'meta-report instruction must be gone');
@@ -101,13 +103,27 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
101
103
  assert.ok(!text.includes('内訳の読み方'), 'glossary block must be gone');
102
104
  assert.ok(!text.includes('現在進行中の作業の active work context'), 'verbose framing must be gone');
103
105
 
104
- // 残るのは 3 行: 現在地参照案内 + 自然な続き + Bash 呼び出し方法
105
- assert.match(text, /下の「現在地」が直前のやりとりです/);
106
- assert.match(text, /直前の対話の自然な続きとして応答してください/);
106
+ // A 経路の必須シグナル: 「あなた自身が直前にユーザーと交わした会話」 + 「新規依頼ではなく続き」
107
+ // + 短い指示の扱い + 「新規会話ではない」明示
108
+ assert.match(text, /あなた自身が直前にユーザーと交わした会話/);
109
+ assert.match(text, /新規依頼ではなく、上記スレッドの \*\*続き\*\*/);
110
+ assert.match(text, /続きよろしく.*OK.*次は?/s);
111
+ assert.match(text, /新規会話ではない/);
112
+ // β 経路 (early-style explicit report-back instruction): モデルが冒頭で
113
+ // 「引き継いだ状態で続けます」と明示的に表明することで、user が体感する継続感を強める
114
+ assert.match(text, /応答の冒頭で必ず以下を 1 行宣言/);
115
+ assert.match(text, /Throughline で前のセッションから .* ターン分の記憶を引き継いだ状態で続けます/);
107
116
  assert.match(
108
117
  text,
109
- /\*\*各ターンの詳細の取得方法\*\*: \*\*`Bash` ツールで `throughline detail HH:MM:SS` を実行\*\* \(該当ターンの本文+詳細を stdout に返します\)/,
118
+ /\*\*各ターンの詳細\*\*: \*\*`Bash` ツールで `throughline detail HH:MM:SS` を実行\*\* \(該当ターンの本文+詳細を stdout に返します\)/,
110
119
  );
120
+
121
+ // v2.1: 古い番号リスト (1/2/3) を最新ユーザーが「2 をやれ」のように参照しても、
122
+ // 直前アシスタントで既に実行済みなら再実行ではなく結果確認に回るというガード。
123
+ // (このセッションで実際にハマった misread の再発防止)
124
+ assert.match(text, /古い番号リストの再実行禁止/);
125
+ assert.match(text, /既に直前アシスタントターンで実装\/実行済み/);
126
+ assert.match(text, /最新アシスタント発話の指示が、過去ターンのリストへの参照より上位/);
111
127
  });
112
128
 
113
129
  test('buildResumeContext: 現在地 anchor surfaces the latest user/assistant exchange above L1/L2', () => {