throughline 0.6.2 → 0.7.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 (72) hide show
  1. package/CHANGELOG.md +79 -4
  2. package/README.md +77 -25
  3. package/bin/throughline.mjs +14 -0
  4. package/docs/00_overview.md +12 -0
  5. package/docs/02_clear_auto_handoff_plan.md +39 -13
  6. package/docs/04_public_release_plan.md +2 -1
  7. package/docs/13_native_factory_diagnostics_plan.md +4 -2
  8. package/docs/14_observer_completed_turn_feed_plan.md +290 -0
  9. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
  10. package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
  11. package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
  12. package/docs/adr/0004-observer-db-pair-projection.md +71 -0
  13. package/docs/adr/0005-observer-read-pagination.md +51 -0
  14. package/docs/adr/0006-observer-page-offset-proof.md +33 -0
  15. package/docs/adr/0007-observer-read-cli-contract.md +61 -0
  16. package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
  17. package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
  18. package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
  19. package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
  20. package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
  21. package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
  22. package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
  23. package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
  24. package/package.json +1 -1
  25. package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -0
  26. package/rag/INDEX.md +4 -0
  27. package/src/auditor-context.mjs +92 -11
  28. package/src/auditor-context.test.mjs +116 -1
  29. package/src/baton.mjs +27 -7
  30. package/src/baton.test.mjs +44 -0
  31. package/src/body-digest.mjs +9 -0
  32. package/src/cli/auditor-context.test.mjs +1 -1
  33. package/src/cli/factory-diagnostics.mjs +5 -2
  34. package/src/cli/factory-diagnostics.test.mjs +31 -2
  35. package/src/cli/observer-read.mjs +73 -0
  36. package/src/cli/observer-read.test.mjs +93 -0
  37. package/src/cli/observer-wait.mjs +123 -0
  38. package/src/cli/observer-wait.test.mjs +167 -0
  39. package/src/codex-rollout-memory.mjs +13 -0
  40. package/src/codex-rollout-memory.test.mjs +27 -0
  41. package/src/codex-thread-index.mjs +1 -1
  42. package/src/codex-thread-index.test.mjs +18 -0
  43. package/src/completed-turn-receipts.mjs +373 -0
  44. package/src/completed-turn-receipts.test.mjs +186 -0
  45. package/src/db-schema.test.mjs +9 -2
  46. package/src/db.mjs +20 -1
  47. package/src/decision-log.mjs +24 -0
  48. package/src/factory-diagnostics.mjs +0 -1
  49. package/src/factory-diagnostics.test.mjs +18 -0
  50. package/src/haiku-summarizer.mjs +93 -16
  51. package/src/haiku-summarizer.test.mjs +118 -9
  52. package/src/handoff-executor.mjs +159 -0
  53. package/src/hook-entrypoints.test.mjs +192 -12
  54. package/src/observer-codex-projection.test.mjs +49 -0
  55. package/src/observer-turn-feed.mjs +392 -0
  56. package/src/observer-turn-feed.test.mjs +339 -0
  57. package/src/observer-turn-wait.mjs +102 -0
  58. package/src/observer-turn-wait.test.mjs +122 -0
  59. package/src/pending-handoff.mjs +96 -0
  60. package/src/pending-handoff.test.mjs +107 -0
  61. package/src/prompt-submit.mjs +46 -1
  62. package/src/resume-context.mjs +226 -62
  63. package/src/resume-context.test.mjs +134 -1
  64. package/src/runtime-error-store.mjs +74 -32
  65. package/src/runtime-error-store.test.mjs +51 -3
  66. package/src/session-start.mjs +70 -233
  67. package/src/transcript-reader.mjs +32 -0
  68. package/src/turn-backfill.mjs +3 -2
  69. package/src/turn-backfill.test.mjs +10 -4
  70. package/src/turn-processor.mjs +90 -1
  71. package/src/turn-processor.test.mjs +141 -0
  72. package/src/windows-acl-test-helper.mjs +29 -0
@@ -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
- * 【引き継ぎ条件 (2 経路)】 docs/02_clear_auto_handoff_plan.md
7
+ * 【二相ハンドオフ】 ADR 0014 / docs/02_clear_auto_handoff_plan.md
8
8
  *
9
- * 1. baton path: ユーザーが旧セッションで `/tl` を打つと UserPromptSubmit hook が
10
- * handoff_batons session_id を書く。本 hook が TTL 1 時間以内に消費して
11
- * 前任を merge + 引継ぎ stdout 注入。`source` 値関係なく発火。
12
- * 2. auto path: `source='clear'` かつ env `THROUGHLINE_DISABLE_AUTO_HANDOFF` が
13
- * `'1'` でない場合、同 project_path の最新 Claude unmerged session を
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. baton path 判定 (consumeBaton + mergeSpecificPredecessor)
23
- * 3. baton 無し かつ source='clear' かつ env disable 無し → auto path 判定
24
- * 4. 合流成立なら curated memory (L1+L2+L3 refs) を「引き継ぎヘッダ」付きで stdout 注入
25
- * 5. 判定結果を ~/.throughline/logs/inheritance-decision.log に記録
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 { consumeBaton } from './baton.mjs';
30
- import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
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 { appendFileSync, existsSync, mkdirSync } from 'node:fs';
36
- import { randomBytes } from 'node:crypto';
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 を返す (auto path 用 predecessor)。
71
- * Codex session (`codex:*`) と現セッション自身は除外。
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
- return (
80
- db
81
- .prepare(
82
- `SELECT session_id FROM sessions
83
- WHERE lower(project_path) = lower(?)
84
- AND merged_into IS NULL
85
- AND session_id != ?
86
- AND session_id NOT LIKE 'codex:%'
87
- ORDER BY updated_at DESC
88
- LIMIT 1`,
89
- )
90
- .get(projectPath, currentSessionId) ?? null
91
- );
92
- }
93
-
94
- function logDecision(entry) {
95
- const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
96
- try {
97
- mkdirSync(dirname(path), { recursive: true });
98
- appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
99
- } catch (err) {
100
- const msg = err instanceof Error ? err.message : 'unknown';
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. baton 消費
139
- const baton = consumeBaton(db, { projectPath, now });
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
- if (baton.sessionId) {
147
- // baton path
148
- triggeredPath = 'baton';
149
- const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
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
- const { target: predecessorId } = resolveMergeTarget(db, predRow.session_id);
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
- logBackfill({
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
- // 5. SPIKE: marker file あり + merge 成立 + transcript_path あり の 3 条件で
272
- // L2 を user/assistant role 付きで transcript_path にも append する。
273
- // 本実装ではない (docs/10_transcript_injection_plan.md Phase 0-2)。
274
- //
275
- // tracer: 末尾 assistant 行に stdout 注入には含まれない一意トークンを付与する。
276
- // 次の /clear 後に Claude が tracer を再現できれば JSONL 経路はモデル可視。
277
- let spikeResult = null;
278
- const spikeMarkerExists = existsSync(SPIKE_MARKER_PATH);
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
- baton_session_id: baton.sessionId ?? null,
308
- baton_age_ms: baton.ageMs ?? null,
309
- baton_skip_reason: baton.skipReason ?? null,
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)にしばしば含まれる色コードを剥がす。
@@ -59,12 +59,12 @@ export function deriveTranscriptPath(projectPath, sessionId) {
59
59
  * @param {string} opts.originSessionId transcript を所有する origin session_id
60
60
  * @param {string|null|undefined} opts.transcriptPath
61
61
  * @param {number} opts.now timestamp 欠損時の fallback epoch ms
62
- * @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null}}
62
+ * @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null, turnNumbers: number[]}}
63
63
  */
64
64
  export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
65
65
  const groups = getLogicalTurnGroups(transcriptPath);
66
66
  if (groups.length === 0) {
67
- return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null };
67
+ return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] };
68
68
  }
69
69
 
70
70
  const existing = new Set(
@@ -127,5 +127,6 @@ export function backfillBodies(db, { targetSessionId, originSessionId, transcrip
127
127
  insertedTurns,
128
128
  skippedExisting,
129
129
  lastTurnNumber: groups[groups.length - 1].representative.index,
130
+ turnNumbers: groups.map((group) => group.representative.index),
130
131
  };
131
132
  }
@@ -58,7 +58,13 @@ test('backfillBodies: multi-fragment group uses last fragment index for both bod
58
58
  const result = backfillBodies(db, {
59
59
  targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
60
60
  });
61
- assert.deepEqual(result, { groups: 2, insertedTurns: 2, skippedExisting: 0, lastTurnNumber: 4 });
61
+ assert.deepEqual(result, {
62
+ groups: 2,
63
+ insertedTurns: 2,
64
+ skippedExisting: 0,
65
+ lastTurnNumber: 4,
66
+ turnNumbers: [2, 4],
67
+ });
62
68
  assert.deepEqual(
63
69
  db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
64
70
  [
@@ -103,7 +109,7 @@ test('backfillBodies: user-only group is dropped', () => {
103
109
  backfillBodies(db, {
104
110
  targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
105
111
  }),
106
- { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
112
+ { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] },
107
113
  );
108
114
  });
109
115
  });
@@ -193,13 +199,13 @@ test('backfillBodies: sidechain entries and missing or empty paths produce no gr
193
199
  backfillBodies(db, {
194
200
  targetSessionId: 'target', originSessionId: 'origin', transcriptPath: null, now: 999,
195
201
  }),
196
- { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
202
+ { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] },
197
203
  );
198
204
  assert.deepEqual(
199
205
  backfillBodies(db, {
200
206
  targetSessionId: 'target', originSessionId: 'origin', transcriptPath: '', now: 999,
201
207
  }),
202
- { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
208
+ { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] },
203
209
  );
204
210
  },
205
211
  );
@@ -33,6 +33,7 @@
33
33
  import { getDb } from './db.mjs';
34
34
  import {
35
35
  readRawEntries,
36
+ readLatestLogicalTurnCompletion,
36
37
  sliceCurrentTurnEntries,
37
38
  extractDetailBlocks,
38
39
  } from './transcript-reader.mjs';
@@ -44,9 +45,48 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
44
45
  import { readLatestUsage } from './transcript-usage.mjs';
45
46
  import { pathToFileURL } from 'node:url';
46
47
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
48
+ import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
47
49
 
48
50
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
49
51
  export const L2_WINDOW = 20;
52
+ export const CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS = 2_000;
53
+ export const CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS = 25;
54
+
55
+ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
56
+
57
+ /**
58
+ * Claude Stop payloadのassistant identityがlatest user groupへ永続化されるまで待つ。
59
+ * markerは本文ソースにせず、transcript可視化のbarrierにだけ使う。
60
+ */
61
+ export async function waitForClaudeStopTranscriptFlush({
62
+ transcriptPath,
63
+ lastAssistantMessage,
64
+ timeoutMs = CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS,
65
+ intervalMs = CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS,
66
+ readCompletion = readLatestLogicalTurnCompletion,
67
+ now = Date.now,
68
+ wait = delay,
69
+ }) {
70
+ if (typeof lastAssistantMessage !== 'string' || lastAssistantMessage.length === 0) {
71
+ return { status: 'marker_unavailable' };
72
+ }
73
+ const deadline = now() + timeoutMs;
74
+ for (;;) {
75
+ const completion = readCompletion(transcriptPath);
76
+ if (completion?.assistantContent === lastAssistantMessage) {
77
+ return {
78
+ status: 'ready',
79
+ userTurnNumber: completion.userTurnNumber,
80
+ assistantTurnNumber: completion.assistantTurnNumber,
81
+ };
82
+ }
83
+ const remaining = deadline - now();
84
+ if (remaining <= 0) {
85
+ throw new Error('Claude Stop transcript completion was not visible before deadline');
86
+ }
87
+ await wait(Math.min(intervalMs, remaining));
88
+ }
89
+ }
50
90
 
51
91
  /**
52
92
  * target 配下の distinct (origin_session_id, turn_number) ターン数を返す。
@@ -93,6 +133,36 @@ export function pickOldestUnsummarizedTurn(db, target) {
93
133
  return row ?? null;
94
134
  }
95
135
 
136
+ /**
137
+ * L2 commit 済みの completed pair だけを Claude receipt store へ publish する。
138
+ * L1/L3 より前に呼び、receipt の失敗は呼び出し元へそのまま伝える。
139
+ */
140
+ export function publishCapturedClaudeCompletionReceipt(db, {
141
+ target,
142
+ origin,
143
+ turnNumber,
144
+ projectPath,
145
+ receiptOptions,
146
+ }) {
147
+ const completedPair = db.prepare(
148
+ `SELECT role, text, created_at FROM bodies
149
+ WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND role IN ('user', 'assistant')`,
150
+ ).all(target, origin, turnNumber);
151
+ const completedUser = completedPair.find((row) => row.role === 'user');
152
+ const completedAssistant = completedPair.find((row) => row.role === 'assistant');
153
+ if (!completedUser || !completedAssistant) {
154
+ throw new Error('completed pair was not captured before receipt publication');
155
+ }
156
+ return writeCompletedTurnReceipt({
157
+ projectPath,
158
+ targetSessionId: target,
159
+ originSessionId: origin,
160
+ userBody: completedUser.text,
161
+ assistantBody: completedAssistant.text,
162
+ completedAt: completedAssistant.created_at,
163
+ }, receiptOptions);
164
+ }
165
+
96
166
  /**
97
167
  * user と assistant のペアを結合して L2 要約用テキストを作る。
98
168
  * @param {{content: string} | null} userTurn
@@ -121,7 +191,7 @@ export async function run() {
121
191
  });
122
192
 
123
193
  const payload = JSON.parse(raw || '{}');
124
- const { session_id, transcript_path, cwd } = payload;
194
+ const { session_id, transcript_path, cwd, last_assistant_message } = payload;
125
195
  if (!session_id) throw new Error('Missing session_id in Stop payload');
126
196
 
127
197
  // VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
@@ -134,6 +204,11 @@ export async function run() {
134
204
  process.stderr.write(`[vscode-task] ${msg}\n`);
135
205
  }
136
206
 
207
+ await waitForClaudeStopTranscriptFlush({
208
+ transcriptPath: transcript_path,
209
+ lastAssistantMessage: last_assistant_message,
210
+ });
211
+
137
212
  // Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
138
213
  // アシスタント応答終了時刻まで追従する
139
214
  writeSessionState({
@@ -190,6 +265,20 @@ export async function run() {
190
265
 
191
266
  const turnNumber = backfill.lastTurnNumber;
192
267
 
268
+ // Claude Stop hook が completion boundary であることを受け、L2 の user/assistant
269
+ // pair が DB に commit 済みであることを確認してから全logical turnのreceiptを時系列publishする。
270
+ // 過去のStopでDBだけ回収済みだったpairもreceipt storeの冪等性で穴埋めする。
271
+ // receipt failure は Stop hook の failure として上位へ伝播させる。L1/L3/usage は
272
+ // receipt 後の派生処理なので、そこで失敗しても completed pair を取り消さない。
273
+ for (const completedTurnNumber of backfill.turnNumbers) {
274
+ publishCapturedClaudeCompletionReceipt(db, {
275
+ target,
276
+ origin,
277
+ turnNumber: completedTurnNumber,
278
+ projectPath: cwd ?? process.cwd(),
279
+ });
280
+ }
281
+
193
282
  // L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn) が
194
283
  // WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。
195
284
  // 20 ターン以内で終わる作業では Haiku コストゼロ。