throughline 0.6.3 → 0.8.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.
- package/CHANGELOG.md +101 -1
- package/README.md +86 -28
- package/bin/throughline.mjs +20 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +47 -13
- package/docs/04_public_release_plan.md +1 -0
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/docs/adr/0016-push-pull-recall-injection.md +93 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +1 -0
- package/src/cli/factory-diagnostics.test.mjs +6 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +374 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +161 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +337 -60
- package/src/resume-context.test.mjs +228 -1
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +142 -0
- package/src/windows-acl-test-helper.mjs +2 -2
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildResumeContext,
|
|
6
|
+
buildBudgetedResumeContext,
|
|
7
|
+
INJECTION_BUDGET_CHARS,
|
|
8
|
+
} from './resume-context.mjs';
|
|
5
9
|
|
|
6
10
|
function makeDb() {
|
|
7
11
|
const db = new DatabaseSync(':memory:');
|
|
@@ -623,3 +627,226 @@ test('buildResumeContext: ignores inflightMemo (kept only for signature compatib
|
|
|
623
627
|
assert.ok(text);
|
|
624
628
|
assert.ok(!text.includes('**Next**: keep going'));
|
|
625
629
|
});
|
|
630
|
+
|
|
631
|
+
// ---- buildBudgetedResumeContext (ADR 0014: hook stdout の 10k file 化対策) ----
|
|
632
|
+
|
|
633
|
+
test('INJECTION_BUDGET_CHARS stays under the measured 10k persisted-output limit', () => {
|
|
634
|
+
assert.ok(INJECTION_BUDGET_CHARS <= 9_501, '実測 inline 通過上限 9,501 字以下であること');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test('budgeted: under budget keeps all L2 turns, no L1 section, guidance always present', () => {
|
|
638
|
+
const db = makeDb();
|
|
639
|
+
insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'user', text: 'short question', createdAt: 1000 });
|
|
640
|
+
insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'assistant', text: 'short answer', createdAt: 1100 });
|
|
641
|
+
|
|
642
|
+
const budgeted = buildBudgetedResumeContext(db, { sessionId: 'new', isInheritance: true });
|
|
643
|
+
|
|
644
|
+
assert.ok(budgeted);
|
|
645
|
+
assert.equal(budgeted.injectedL2Turns, 1);
|
|
646
|
+
assert.equal(budgeted.remainingL2Turns, 0);
|
|
647
|
+
assert.equal(budgeted.olderTurns, 0);
|
|
648
|
+
assert.equal(budgeted.truncatedNewestL2, false);
|
|
649
|
+
assert.ok(budgeted.totalChars <= INJECTION_BUDGET_CHARS);
|
|
650
|
+
// 案内セクションは無条件表示 (データ無し側も「なし」と明示)
|
|
651
|
+
assert.ok(budgeted.text.includes('### さらに前の記憶(必要な時だけ取得)'));
|
|
652
|
+
assert.match(budgeted.text, /これより前の続き: なし/);
|
|
653
|
+
assert.match(budgeted.text, /それ以前のターン: なし/);
|
|
654
|
+
// L1 セクションは注入しない
|
|
655
|
+
assert.ok(!budgeted.text.includes('### それ以前の要約 (L1)'));
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('budgeted: packs whole turns newest-first, bakes --before/--last/--session into guidance', () => {
|
|
659
|
+
const db = makeDb();
|
|
660
|
+
// 各 ~800 字 × 10 ターン = 本文だけで ~8,000 字 → maxChars 5000 で古いターンが落ちる
|
|
661
|
+
for (let turn = 1; turn <= 10; turn += 1) {
|
|
662
|
+
insertBody(db, {
|
|
663
|
+
session: 'new',
|
|
664
|
+
origin: 'old',
|
|
665
|
+
turn,
|
|
666
|
+
role: 'user',
|
|
667
|
+
text: `q-${String(turn).padStart(2, '0')} question`,
|
|
668
|
+
createdAt: 1000 + turn * 100,
|
|
669
|
+
});
|
|
670
|
+
insertBody(db, {
|
|
671
|
+
session: 'new',
|
|
672
|
+
origin: 'old',
|
|
673
|
+
turn,
|
|
674
|
+
role: 'assistant',
|
|
675
|
+
text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(800),
|
|
676
|
+
createdAt: 1050 + turn * 100,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
681
|
+
sessionId: 'new',
|
|
682
|
+
isInheritance: true,
|
|
683
|
+
maxChars: 5000,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
assert.ok(budgeted);
|
|
687
|
+
assert.ok(budgeted.totalChars <= 5000, `totalChars ${budgeted.totalChars} must fit budget`);
|
|
688
|
+
assert.ok(budgeted.injectedL2Turns > 0, 'newest turns must be injected');
|
|
689
|
+
assert.ok(budgeted.remainingL2Turns > 0, 'some old turns must be left for pull');
|
|
690
|
+
assert.equal(budgeted.injectedL2Turns + budgeted.remainingL2Turns, 10);
|
|
691
|
+
assert.ok(budgeted.text.includes('turn-10'), 'newest L2 turn must survive');
|
|
692
|
+
assert.ok(!budgeted.text.includes('turn-01 '), 'oldest L2 turn must be left for pull');
|
|
693
|
+
|
|
694
|
+
// ターン原子性: 注入されたターンは user 行と assistant 行が揃っている
|
|
695
|
+
const oldestInjectedTurn = 10 - budgeted.injectedL2Turns + 1;
|
|
696
|
+
const tag = String(oldestInjectedTurn).padStart(2, '0');
|
|
697
|
+
assert.ok(budgeted.text.includes(`q-${tag} question`), 'user row of injected turn must be present');
|
|
698
|
+
assert.ok(budgeted.text.includes(`turn-${tag} `), 'assistant row of injected turn must be present');
|
|
699
|
+
|
|
700
|
+
// 案内: --before は実注入最古ターンの min(created_at) の ISO ms、--last は残り件数
|
|
701
|
+
const boundaryMs = 1000 + oldestInjectedTurn * 100; // 最古注入ターンの user 行時刻
|
|
702
|
+
const iso = new Date(boundaryMs).toISOString().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
703
|
+
assert.match(
|
|
704
|
+
budgeted.text,
|
|
705
|
+
new RegExp(
|
|
706
|
+
`これより前の続き${budgeted.remainingL2Turns}ターン .*` +
|
|
707
|
+
'`throughline recall --l2 --session new ' +
|
|
708
|
+
`--before ${iso} --last ${budgeted.remainingL2Turns}\``,
|
|
709
|
+
),
|
|
710
|
+
'guidance must bake session, ISO ms boundary, and remaining count',
|
|
711
|
+
);
|
|
712
|
+
// 全 10 ターンとも窓内なので、窓より古い側は正直に「なし」と明示される
|
|
713
|
+
assert.match(budgeted.text, /それ以前のターン: なし/);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test('budgeted: --l1 guidance bakes the same boundary and the --l2 skip count', () => {
|
|
717
|
+
const db = makeDb();
|
|
718
|
+
// 窓 (20) + 窓外 5 ターン。予算を絞って窓内にも pull 残りを作る
|
|
719
|
+
for (let turn = 1; turn <= 25; turn += 1) {
|
|
720
|
+
insertBody(db, {
|
|
721
|
+
session: 'new',
|
|
722
|
+
origin: 'old',
|
|
723
|
+
turn,
|
|
724
|
+
role: 'assistant',
|
|
725
|
+
text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(700),
|
|
726
|
+
createdAt: 1000 + turn * 100,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
731
|
+
sessionId: 'new',
|
|
732
|
+
isInheritance: true,
|
|
733
|
+
maxChars: 4000,
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
assert.ok(budgeted);
|
|
737
|
+
assert.ok(budgeted.remainingL2Turns > 0);
|
|
738
|
+
assert.equal(budgeted.olderTurns, 5);
|
|
739
|
+
const oldestInjectedTurn = 25 - budgeted.injectedL2Turns + 1;
|
|
740
|
+
const boundaryMs = 1000 + oldestInjectedTurn * 100;
|
|
741
|
+
const iso = new Date(boundaryMs).toISOString().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
742
|
+
assert.match(
|
|
743
|
+
budgeted.text,
|
|
744
|
+
new RegExp(`--l2 --session new --before ${iso} --last ${budgeted.remainingL2Turns}`),
|
|
745
|
+
);
|
|
746
|
+
assert.match(
|
|
747
|
+
budgeted.text,
|
|
748
|
+
new RegExp(`--l1 --session new --before ${iso} --skip ${budgeted.remainingL2Turns}`),
|
|
749
|
+
'--l1 guidance must bake the same boundary and skip count',
|
|
750
|
+
);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('budgeted: window turns beyond budget never fall into a blank band (guidance covers them)', () => {
|
|
754
|
+
const db = makeDb();
|
|
755
|
+
// 40 ターン: 窓は最新 20 ターン、そこからさらに予算落ちが出る構成
|
|
756
|
+
for (let turn = 1; turn <= 40; turn += 1) {
|
|
757
|
+
insertBody(db, {
|
|
758
|
+
session: 'new',
|
|
759
|
+
origin: 'old',
|
|
760
|
+
turn,
|
|
761
|
+
role: 'assistant',
|
|
762
|
+
text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(700),
|
|
763
|
+
createdAt: 1000 + turn * 1000,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
768
|
+
sessionId: 'new',
|
|
769
|
+
isInheritance: true,
|
|
770
|
+
maxChars: 4000,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
assert.ok(budgeted);
|
|
774
|
+
assert.ok(budgeted.totalChars <= 4000);
|
|
775
|
+
// 窓 20 ターンのうち注入できなかった分は全部 recall --l2 の担当として案内される
|
|
776
|
+
assert.equal(budgeted.injectedL2Turns + budgeted.remainingL2Turns, 20);
|
|
777
|
+
// 窓より古い 20 ターンは --l1 側 (未要約でも件数として見える)
|
|
778
|
+
assert.equal(budgeted.olderTurns, 20);
|
|
779
|
+
assert.match(budgeted.text, /それ以前の全20ターン(要約済み 0 \/ 未要約 20)/);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test('budgeted: older summarized/unsummarized counts are honest in the guidance', () => {
|
|
783
|
+
const db = makeDb();
|
|
784
|
+
for (let turn = 1; turn <= 25; turn += 1) {
|
|
785
|
+
insertBody(db, {
|
|
786
|
+
session: 'new',
|
|
787
|
+
origin: 'old',
|
|
788
|
+
turn,
|
|
789
|
+
role: 'assistant',
|
|
790
|
+
text: `turn-${turn}`,
|
|
791
|
+
createdAt: 1000 + turn * 1000,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
// 窓 (最新 20 ターン = turn 6..25) より古い turn 1..5 のうち 2 件だけ要約済み
|
|
795
|
+
insertSkeleton(db, { session: 'new', origin: 'old', turn: 1, role: 'assistant', summary: 's1', createdAt: 90_000 });
|
|
796
|
+
insertSkeleton(db, { session: 'new', origin: 'old', turn: 2, role: 'assistant', summary: 's2', createdAt: 90_001 });
|
|
797
|
+
|
|
798
|
+
const budgeted = buildBudgetedResumeContext(db, { sessionId: 'new', isInheritance: true });
|
|
799
|
+
|
|
800
|
+
assert.ok(budgeted);
|
|
801
|
+
assert.equal(budgeted.olderTurns, 5);
|
|
802
|
+
assert.equal(budgeted.olderSummarized, 2);
|
|
803
|
+
assert.match(budgeted.text, /それ以前の全5ターン(要約済み 2 \/ 未要約 3)/);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('budgeted: a single oversized newest L2 row is truncated with a detail pointer', () => {
|
|
807
|
+
const db = makeDb();
|
|
808
|
+
insertBody(db, {
|
|
809
|
+
session: 'new',
|
|
810
|
+
origin: 'old',
|
|
811
|
+
turn: 1,
|
|
812
|
+
role: 'assistant',
|
|
813
|
+
text: 'HEAD-MARKER ' + 'y'.repeat(20_000),
|
|
814
|
+
createdAt: 1000,
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
818
|
+
sessionId: 'new',
|
|
819
|
+
isInheritance: true,
|
|
820
|
+
maxChars: 4000,
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
assert.ok(budgeted);
|
|
824
|
+
assert.ok(budgeted.totalChars <= 4000);
|
|
825
|
+
assert.equal(budgeted.truncatedNewestL2, true);
|
|
826
|
+
assert.ok(budgeted.text.includes('HEAD-MARKER'), 'the head of the newest row must survive');
|
|
827
|
+
assert.match(budgeted.text, /全文: throughline detail /, 'truncation must point to detail command');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test('budgeted: header and anchor always survive even under pressure', () => {
|
|
831
|
+
const db = makeDb();
|
|
832
|
+
for (let turn = 1; turn <= 5; turn += 1) {
|
|
833
|
+
insertBody(db, {
|
|
834
|
+
session: 'new',
|
|
835
|
+
origin: 'old',
|
|
836
|
+
turn,
|
|
837
|
+
role: 'assistant',
|
|
838
|
+
text: 'z'.repeat(3000),
|
|
839
|
+
createdAt: 1000 + turn,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
844
|
+
sessionId: 'new',
|
|
845
|
+
isInheritance: true,
|
|
846
|
+
maxChars: 4000,
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
assert.ok(budgeted);
|
|
850
|
+
assert.match(budgeted.text, /^## Throughline: 直前スレッドの継続応答用コンテキスト/);
|
|
851
|
+
assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
|
|
852
|
+
});
|
|
@@ -25,7 +25,8 @@ export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unav
|
|
|
25
25
|
const DEFAULT_SNAPSHOT_LIMIT = 256;
|
|
26
26
|
const BEST_EFFORT_TIMEOUT_MS = 750;
|
|
27
27
|
const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
|
|
28
|
-
|
|
28
|
+
// CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
|
|
29
|
+
const WINDOWS_ACL_TIMEOUT_MS = 15_000;
|
|
29
30
|
const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
|
|
30
31
|
const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
|
|
31
32
|
|
|
@@ -101,7 +101,7 @@ test('runtime error store: one Windows mutation spends ACL processes only on dis
|
|
|
101
101
|
|
|
102
102
|
assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
|
|
103
103
|
assert.equal(calls.length, 4, 'directory apply, existing lock/store verify, and replacement store apply are distinct');
|
|
104
|
-
assert.ok(calls.every((call) => call.options.timeout ===
|
|
104
|
+
assert.ok(calls.every((call) => call.options.timeout === 15_000));
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test('runtime error store: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
|
package/src/session-start.mjs
CHANGED
|
@@ -1,65 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* SessionStart hook — セッション登録 +
|
|
3
|
+
* SessionStart hook — セッション登録 + 引き継ぎ intent 登録(二相ハンドオフの第一相)
|
|
4
4
|
*
|
|
5
5
|
* stdin: { session_id, source, cwd, transcript_path, hook_event_name }
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 【二相ハンドオフ】 ADR 0014 / docs/02_clear_auto_handoff_plan.md
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 自動 merge して注入。
|
|
9
|
+
* Claude Code は同一 project_path に対し短時間 (実測 315–488ms) に複数の
|
|
10
|
+
* SessionStart を発火させることがあり、一部は transcript を一度も生成しない
|
|
11
|
+
* 幽霊セッションになる。SessionStart 時点では実体と幽霊を判別できない
|
|
12
|
+
* (transcript は本物でも hook より数百 ms 遅れて作られる)ため、
|
|
13
|
+
* この hook では merge も注入も行わない:
|
|
15
14
|
*
|
|
16
|
-
* 両方同時成立はしない (consumeBaton が先発、baton ありなら baton path、
|
|
17
|
-
* なければ source 判定)。env で OFF にしたユーザーは `/tl` を打ってから
|
|
18
|
-
* 新セッションスタートで baton path を使う。
|
|
19
|
-
*
|
|
20
|
-
* 役割:
|
|
21
15
|
* 1. sessions テーブルに新セッションを INSERT OR IGNORE
|
|
22
|
-
* 2.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
16
|
+
* 2. auto path (source='clear' かつ env THROUGHLINE_DISABLE_AUTO_HANDOFF != '1')
|
|
17
|
+
* なら前任candidateをこの時点で解決して凍結(transcript 実在フィルタ付き —
|
|
18
|
+
* 幽霊 twin を前任に選ばない)
|
|
19
|
+
* 3. registerPendingHandoff で intent を登録
|
|
20
|
+
* 4. 判定を ~/.throughline/logs/inheritance-decision.log に記録 (phase='session-start')
|
|
21
|
+
*
|
|
22
|
+
* バトンの消費・merge・注入は最初の UserPromptSubmit (= 実体の証明) で行う。
|
|
23
|
+
* 幽霊はプロンプトを発火しないため記憶を奪えない。
|
|
26
24
|
*/
|
|
27
25
|
|
|
28
26
|
import { getDb } from './db.mjs';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import { backfillBodies, deriveTranscriptPath, logBackfill } from './turn-backfill.mjs';
|
|
32
|
-
import { buildResumeContext } from './resume-context.mjs';
|
|
27
|
+
import { registerPendingHandoff } from './pending-handoff.mjs';
|
|
28
|
+
import { deriveTranscriptPath } from './turn-backfill.mjs';
|
|
33
29
|
import { readAllSessionStates } from './state-file.mjs';
|
|
34
30
|
import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import { join, dirname } from 'node:path';
|
|
38
|
-
import { homedir } from 'node:os';
|
|
31
|
+
import { logDecision } from './decision-log.mjs';
|
|
32
|
+
import { existsSync } from 'node:fs';
|
|
39
33
|
import { pathToFileURL } from 'node:url';
|
|
40
34
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
41
35
|
|
|
42
|
-
// SPIKE ONLY — Phase 0-2 / 0-4 検証用。marker file 削除で無効化される。
|
|
43
|
-
// docs/10_transcript_injection_plan.md §3 Phase 0-2 参照。
|
|
44
|
-
const SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-inject.flag');
|
|
45
|
-
|
|
46
|
-
// Phase 0-6: initialUserMessage が interactive モードで効くか実機検証する experimental switch。
|
|
47
|
-
// flag 存在時、SessionStart hook は plain stdout の代わりに JSON 出力に切り替わり、
|
|
48
|
-
// hookSpecificOutput.initialUserMessage に tracer 入りメッセージを乗せる。
|
|
49
|
-
// openclaude の OSS 実装では「headless 専用」と記載されているが、real CC の挙動は未確認。
|
|
50
|
-
const INITIAL_USER_MESSAGE_TEST_FLAG = join(homedir(), '.throughline', 'initial-user-message-test.flag');
|
|
51
|
-
|
|
52
|
-
function logInitialUserMessageTest(entry) {
|
|
53
|
-
const path = join(homedir(), '.throughline', 'logs', 'initial-user-message-test.log');
|
|
54
|
-
try {
|
|
55
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
56
|
-
appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
57
|
-
} catch (err) {
|
|
58
|
-
const msg = err instanceof Error ? err.message : 'unknown';
|
|
59
|
-
process.stderr.write(`[session-start:initialUserMessage-test-log] ${msg}\n`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
36
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
64
37
|
|
|
65
38
|
function isAutoHandoffDisabled(env) {
|
|
@@ -67,8 +40,13 @@ function isAutoHandoffDisabled(env) {
|
|
|
67
40
|
}
|
|
68
41
|
|
|
69
42
|
/**
|
|
70
|
-
* 同 project_path の最新 Claude unmerged session
|
|
71
|
-
*
|
|
43
|
+
* 同 project_path の最新 Claude unmerged session から、transcript が実在する
|
|
44
|
+
* 最初の candidate を返す (auto path 用 predecessor)。
|
|
45
|
+
*
|
|
46
|
+
* transcript 実在フィルタの理由 (ADR 0014): /clear の二重 SessionStart では
|
|
47
|
+
* 幽霊 twin も sessions 行を持ち、updated_at が最新になるため、フィルタ無しだと
|
|
48
|
+
* 幽霊を前任に選んで実前任を取りこぼす (2026-05 の auto path incident 群)。
|
|
49
|
+
* 実前任は /clear 前に活動していた実体なので transcript を必ず持つ。
|
|
72
50
|
*
|
|
73
51
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
74
52
|
* @param {string} projectPath
|
|
@@ -76,30 +54,30 @@ function isAutoHandoffDisabled(env) {
|
|
|
76
54
|
* @returns {{ session_id: string } | null}
|
|
77
55
|
*/
|
|
78
56
|
function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
process.stderr.write(`[session-start:decision-log] ${msg}\n`);
|
|
57
|
+
const candidates = db
|
|
58
|
+
.prepare(
|
|
59
|
+
`SELECT session_id FROM sessions
|
|
60
|
+
WHERE lower(project_path) = lower(?)
|
|
61
|
+
AND merged_into IS NULL
|
|
62
|
+
AND session_id != ?
|
|
63
|
+
AND session_id NOT LIKE 'codex:%'
|
|
64
|
+
ORDER BY updated_at DESC
|
|
65
|
+
LIMIT 5`,
|
|
66
|
+
)
|
|
67
|
+
.all(projectPath, currentSessionId);
|
|
68
|
+
|
|
69
|
+
if (candidates.length === 0) return null;
|
|
70
|
+
|
|
71
|
+
const states = readAllSessionStates();
|
|
72
|
+
for (const row of candidates) {
|
|
73
|
+
const derived = deriveTranscriptPath(projectPath, row.session_id);
|
|
74
|
+
if (existsSync(derived)) return row;
|
|
75
|
+
const stateTranscriptPath = states.find(
|
|
76
|
+
(state) => state.sessionId === row.session_id,
|
|
77
|
+
)?.transcriptPath;
|
|
78
|
+
if (stateTranscriptPath && existsSync(stateTranscriptPath)) return row;
|
|
102
79
|
}
|
|
80
|
+
return null;
|
|
103
81
|
}
|
|
104
82
|
|
|
105
83
|
export async function run() {
|
|
@@ -135,183 +113,42 @@ export async function run() {
|
|
|
135
113
|
VALUES (?, ?, 'active', ?, ?)`,
|
|
136
114
|
).run(session_id, projectPath, now, now);
|
|
137
115
|
|
|
138
|
-
// 2.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// 3. 引継ぎ判定
|
|
142
|
-
let mergeResult = { merged: false, skipReason: 'no_trigger' };
|
|
143
|
-
let triggeredPath = null;
|
|
116
|
+
// 2. auto path intent: source='clear' なら前任をこの時点で解決して凍結する。
|
|
117
|
+
// 初回プロンプトまでの間に他ウィンドウが動いても「/clear が意味した前任」がずれない。
|
|
144
118
|
const autoDisabled = isAutoHandoffDisabled(process.env);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
mergeResult = mergeSpecificPredecessor(db, {
|
|
151
|
-
newSessionId: session_id,
|
|
152
|
-
predecessorId,
|
|
153
|
-
now,
|
|
154
|
-
});
|
|
155
|
-
} else if (source === 'clear' && !autoDisabled) {
|
|
156
|
-
// auto path: 同 project の最新 Claude unmerged session を自動 predecessor にする
|
|
157
|
-
triggeredPath = 'auto';
|
|
119
|
+
let autoPredecessorId = null;
|
|
120
|
+
let intentNote = null;
|
|
121
|
+
if (source === 'clear' && autoDisabled) {
|
|
122
|
+
intentNote = 'auto_handoff_disabled';
|
|
123
|
+
} else if (source === 'clear') {
|
|
158
124
|
const predRow = findLatestClaudePredecessor(db, projectPath, session_id);
|
|
159
125
|
if (predRow?.session_id) {
|
|
160
|
-
|
|
161
|
-
mergeResult = mergeSpecificPredecessor(db, {
|
|
162
|
-
newSessionId: session_id,
|
|
163
|
-
predecessorId,
|
|
164
|
-
now,
|
|
165
|
-
});
|
|
166
|
-
} else {
|
|
167
|
-
mergeResult = { merged: false, skipReason: 'no_predecessor' };
|
|
168
|
-
}
|
|
169
|
-
} else if (source === 'clear' && autoDisabled) {
|
|
170
|
-
triggeredPath = 'auto-disabled';
|
|
171
|
-
mergeResult = { merged: false, skipReason: 'auto_handoff_disabled' };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
|
|
175
|
-
// 順序厳守: stdout flush を先に完了させてから spike 分岐へ進む。
|
|
176
|
-
// spike が throw しても stdout は既に attachment に保存されている。
|
|
177
|
-
//
|
|
178
|
-
// Phase 0-6: initialUserMessage test flag 存在時は JSON 出力に切り替え、
|
|
179
|
-
// initialUserMessage が interactive モードで messages[] に乗るか実機検証する。
|
|
180
|
-
if (mergeResult.merged) {
|
|
181
|
-
const predecessorId = mergeResult.predecessorId;
|
|
182
|
-
// /clear 直前ターンの取りこぼしを注入前に回収する。前任の transcript path は project path
|
|
183
|
-
// から決定的に導出する — state ファイルは Stop 不発の前任(まさに回収したい事例)では存在しないため補助。
|
|
184
|
-
const derivedTranscriptPath = deriveTranscriptPath(projectPath, predecessorId);
|
|
185
|
-
const stateTranscriptPath = readAllSessionStates().find(
|
|
186
|
-
(state) => state.sessionId === predecessorId,
|
|
187
|
-
)?.transcriptPath;
|
|
188
|
-
const predecessorTranscriptPath = existsSync(derivedTranscriptPath)
|
|
189
|
-
? derivedTranscriptPath
|
|
190
|
-
: stateTranscriptPath && existsSync(stateTranscriptPath)
|
|
191
|
-
? stateTranscriptPath
|
|
192
|
-
: null;
|
|
193
|
-
|
|
194
|
-
if (predecessorTranscriptPath) {
|
|
195
|
-
try {
|
|
196
|
-
const backfill = backfillBodies(db, {
|
|
197
|
-
targetSessionId: session_id,
|
|
198
|
-
originSessionId: predecessorId,
|
|
199
|
-
transcriptPath: predecessorTranscriptPath,
|
|
200
|
-
now,
|
|
201
|
-
});
|
|
202
|
-
logBackfill({
|
|
203
|
-
ts: new Date(now).toISOString(),
|
|
204
|
-
hook: 'session-start',
|
|
205
|
-
session_id,
|
|
206
|
-
target: session_id,
|
|
207
|
-
origin: predecessorId,
|
|
208
|
-
transcript_path: predecessorTranscriptPath,
|
|
209
|
-
groups: backfill.groups,
|
|
210
|
-
inserted_turns: backfill.insertedTurns,
|
|
211
|
-
skipped_existing: backfill.skippedExisting,
|
|
212
|
-
});
|
|
213
|
-
} catch (err) {
|
|
214
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
215
|
-
process.stderr.write(`[session-start:backfill] ${message}\n`);
|
|
216
|
-
logBackfill({
|
|
217
|
-
ts: new Date(now).toISOString(),
|
|
218
|
-
hook: 'session-start',
|
|
219
|
-
session_id,
|
|
220
|
-
target: session_id,
|
|
221
|
-
origin: predecessorId,
|
|
222
|
-
transcript_path: predecessorTranscriptPath,
|
|
223
|
-
error: message,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
126
|
+
autoPredecessorId = predRow.session_id;
|
|
226
127
|
} else {
|
|
227
|
-
|
|
228
|
-
ts: new Date(now).toISOString(),
|
|
229
|
-
hook: 'session-start',
|
|
230
|
-
session_id,
|
|
231
|
-
target: session_id,
|
|
232
|
-
origin: predecessorId,
|
|
233
|
-
transcript_path: null,
|
|
234
|
-
skip_reason: 'no_transcript_path',
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const text = buildResumeContext(db, {
|
|
239
|
-
sessionId: session_id,
|
|
240
|
-
isInheritance: true,
|
|
241
|
-
});
|
|
242
|
-
if (existsSync(INITIAL_USER_MESSAGE_TEST_FLAG)) {
|
|
243
|
-
// TEST MODE: emit JSON with initialUserMessage tracer. Plain stdout は出さない
|
|
244
|
-
// (= 通常 Throughline 案内文無し)。テスト 1 回限定。flag を削除すれば即復帰。
|
|
245
|
-
const tracer = randomBytes(4).toString('hex');
|
|
246
|
-
const initialMessage =
|
|
247
|
-
`[initial-user-tracer: ${tracer}]\n\n` +
|
|
248
|
-
`This text is being delivered via the SessionStart hook's ` +
|
|
249
|
-
`hookSpecificOutput.initialUserMessage field. If you can quote the 8-hex ` +
|
|
250
|
-
`tracer above when asked, it means initialUserMessage IS consumed in ` +
|
|
251
|
-
`interactive mode (not headless-only as openclaude documents).`;
|
|
252
|
-
const jsonOutput = JSON.stringify({
|
|
253
|
-
hookSpecificOutput: {
|
|
254
|
-
hookEventName: 'SessionStart',
|
|
255
|
-
initialUserMessage: initialMessage,
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
process.stdout.write(jsonOutput + '\n');
|
|
259
|
-
logInitialUserMessageTest({
|
|
260
|
-
ts: new Date(now).toISOString(),
|
|
261
|
-
session_id,
|
|
262
|
-
tracer,
|
|
263
|
-
mode: 'json-initial-user-message-only',
|
|
264
|
-
had_resume_context: Boolean(text),
|
|
265
|
-
});
|
|
266
|
-
} else if (text) {
|
|
267
|
-
process.stdout.write(text + '\n');
|
|
128
|
+
intentNote = 'no_predecessor';
|
|
268
129
|
}
|
|
269
130
|
}
|
|
270
131
|
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (spikeMarkerExists && mergeResult.merged && transcript_path) {
|
|
280
|
-
try {
|
|
281
|
-
const { spikeInject, generateSpikeTracer } = await import('./spike-transcript-writer.mjs');
|
|
282
|
-
const tracer = generateSpikeTracer();
|
|
283
|
-
spikeResult = spikeInject({
|
|
284
|
-
db,
|
|
285
|
-
targetJsonlPath: transcript_path,
|
|
286
|
-
newSessionId: session_id,
|
|
287
|
-
cwd: projectPath,
|
|
288
|
-
version: payload.version ?? '2.1.145',
|
|
289
|
-
gitBranch: payload.gitBranch ?? 'main',
|
|
290
|
-
tracer,
|
|
291
|
-
});
|
|
292
|
-
} catch (err) {
|
|
293
|
-
const msg = err instanceof Error ? err.message : 'unknown';
|
|
294
|
-
process.stderr.write(`[spike-inject] ${msg}\n`);
|
|
295
|
-
spikeResult = { error: msg };
|
|
296
|
-
}
|
|
297
|
-
}
|
|
132
|
+
// 3. pending intent 登録。merge / 注入はしない (最初の UserPromptSubmit で実行)。
|
|
133
|
+
registerPendingHandoff(db, {
|
|
134
|
+
sessionId: session_id,
|
|
135
|
+
projectPath,
|
|
136
|
+
source: source ?? null,
|
|
137
|
+
autoPredecessorId,
|
|
138
|
+
now,
|
|
139
|
+
});
|
|
298
140
|
|
|
299
141
|
logDecision({
|
|
300
142
|
ts: new Date(now).toISOString(),
|
|
143
|
+
phase: 'session-start',
|
|
301
144
|
source: source ?? null,
|
|
302
145
|
session_id,
|
|
303
146
|
project_path: projectPath,
|
|
304
147
|
transcript_path: transcript_path ?? null,
|
|
305
|
-
triggered_path: triggeredPath,
|
|
306
148
|
auto_handoff_disabled: autoDisabled,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
merged: mergeResult.merged,
|
|
311
|
-
merge_skip_reason: mergeResult.skipReason ?? null,
|
|
312
|
-
predecessor_id: mergeResult.predecessorId ?? null,
|
|
313
|
-
spike_marker_exists: spikeMarkerExists,
|
|
314
|
-
spike_result: spikeResult,
|
|
149
|
+
auto_predecessor_id: autoPredecessorId,
|
|
150
|
+
intent_note: intentNote,
|
|
151
|
+
pending_registered: true,
|
|
315
152
|
});
|
|
316
153
|
|
|
317
154
|
process.exit(0);
|
|
@@ -141,6 +141,38 @@ export function getLogicalTurnGroups(transcriptPath) {
|
|
|
141
141
|
return groups;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* transcript上のlatest user groupが、現在どのassistant本文まで永続化されたかを返す。
|
|
146
|
+
* 過去のcompleted groupではなく最後のuser以後だけを見るため、同文answerの誤帰属を避ける。
|
|
147
|
+
*
|
|
148
|
+
* @param {string} transcriptPath
|
|
149
|
+
* @returns {{userTurnNumber: number, assistantTurnNumber: number|null, assistantContent: string|null}|null}
|
|
150
|
+
*/
|
|
151
|
+
export function readLatestLogicalTurnCompletion(transcriptPath) {
|
|
152
|
+
const turns = readTranscript(transcriptPath);
|
|
153
|
+
let latestUserIndex = -1;
|
|
154
|
+
for (let index = turns.length - 1; index >= 0; index--) {
|
|
155
|
+
if (turns[index].role === 'user') {
|
|
156
|
+
latestUserIndex = index;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (latestUserIndex < 0) return null;
|
|
161
|
+
|
|
162
|
+
const user = turns[latestUserIndex];
|
|
163
|
+
let representative = null;
|
|
164
|
+
for (let index = latestUserIndex + 1; index < turns.length; index++) {
|
|
165
|
+
const turn = turns[index];
|
|
166
|
+
if (turn.role === 'user') break;
|
|
167
|
+
if (turn.role === 'assistant' && !isJunkAssistantText(turn.content)) representative = turn;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
userTurnNumber: user.turn_number,
|
|
171
|
+
assistantTurnNumber: representative?.turn_number ?? null,
|
|
172
|
+
assistantContent: representative?.content ?? null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
144
176
|
/**
|
|
145
177
|
* ANSI エスケープシーケンスを除去する。
|
|
146
178
|
* ツール出力(特に Bash)にしばしば含まれる色コードを剥がす。
|