throughline 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,22 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.1] — 2026-10-07
14
+
15
+ ### 修正
16
+
17
+ - Claudeのターンの途中で会話記録(transcript)のフォルダが消され、Claude Codeが残りの行だけでファイルを作り直した時、Stopのhookが`Claude Stop transcript completion was not visible before deadline`で失敗し、`HOOK_PROCESS_TURN_FAILED`を数えていたのを直す([ADR 0038](docs/adr/0038-stop-flush-barrier-head-lost-transcript.md))。期限まで待って、transcriptに応答だけがあり利用者の発言が1つも無い時は、失敗に数えず、`backfill.log`に`skipped: "transcript_head_lost"`を残して終わる。ファイルが残らなかった時(`transcript_absent`、[ADR 0029](docs/adr/0029-stop-flush-barrier-absent-transcript.md))と同じ扱いにする。
18
+ - 利用者の発言が残っているのに完了が見えない時は、今までどおり失敗に数える。
19
+
20
+ ### 確認した範囲
21
+
22
+ - Linuxのコンテナ(0.15.5、Claude Code 2.1.292)で2026-10-07に起きた失敗の、transcriptとStopのpayloadを切り離した置き場で再生した。0.15.5は同じ文面で失敗して1回数え、この版は失敗に数えず`transcript_head_lost`を記録した。
23
+
24
+ ### 確認していない範囲
25
+
26
+ - 同じ朝に同じ試験の道具で数えられた残り11回の失敗は、transcriptが後で消されていて、同じ形だったかを直接は見ていない。11回とも、その会話の最後のStopで起きている。
27
+ - macOSとWindowsでは、この形の失敗を再現していない(足した試験はCIの3環境で走る)。
28
+
13
29
  ## [0.16.0] — 2026-10-07
14
30
 
15
31
  ### 変更
@@ -1876,7 +1892,8 @@ two attempts, instrument first instead of patching again.
1876
1892
 
1877
1893
  ---
1878
1894
 
1879
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.0...HEAD
1895
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.1...HEAD
1896
+ [0.16.1]: https://github.com/kitepon/Throughline/compare/v0.16.0...v0.16.1
1880
1897
  [0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
1881
1898
  [0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
1882
1899
  [0.15.4]: https://github.com/kitepon/Throughline/compare/v0.15.3...v0.15.4
@@ -0,0 +1,53 @@
1
+ # ADR 0038: 始まりを失った transcript の Claude Stop は、hook 処理の失敗に数えない
2
+
3
+ 日付: 2026-10-07
4
+
5
+ ## Context
6
+
7
+ 2026-10-07 08:24:17 JST、BellTeam コンテナ(0.15.5)で、解決登録していた `HOOK_PROCESS_TURN_FAILED` が再発した
8
+ (7回目)。理由は `Claude Stop transcript completion was not visible before deadline`。
9
+
10
+ 失敗した会話は、`/tmp/ag-fvIcWA/proj` で動いた Claude Code(2.1.292)の試験用の会話。1つ目のターンの Stop は
11
+ 同じ会話の transcript を読んで 1 turn を保存している(08:23:59)。2つ目のターンの Stop が失敗した。
12
+
13
+ 残っていた transcript は 10 行で、最初の行は 08:24:08.262 の assistant(thinking だけ)、次が 08:24:15.621 の
14
+ 最後の応答だった。user の行は1つも無い。同じファイルの `turn_duration` は `messageCount: 29` と書いている。
15
+ transcript を置くフォルダ(`~/.claude/projects/-tmp-ag-fvIcWA-proj`)の作成時刻は 08:24:15.744 で、
16
+ 会話の作業フォルダ(`/tmp/ag-fvIcWA/proj`)は 08:24:15.861 に消されている。
17
+
18
+ 会話を起動した試験の道具は、回答が端末に出た直後に端末を閉じ、transcript のフォルダと作業フォルダを消す。
19
+ Claude Code は最後の応答の行(thinking と本文)をその後で書くので、フォルダごとファイルを作り直す。
20
+ Stop hook は応答の後に非同期で走り、応答だけのファイルを読んだ、と読める。
21
+
22
+ 同じ道具は同じ朝に続けて動き、08:24〜08:33 の間に同じ文面の失敗が 12 回数えられた(7回目〜18回目)。
23
+ transcript が残っていたのは最初の1回だけで、残りの11回は後でファイルが消されている。11回とも、
24
+ その会話の最後の Stop で、1つ前の Stop は保存できている。
25
+
26
+ ファイルが残らなかった時は ADR 0029 の `transcript_absent` になり、失敗に数えない。今回はファイルが
27
+ 作り直されていたので、ADR 0029 Decision 3(ファイルがあるのに完了が見えない時は失敗)に当たった。
28
+ どちらも「保存する元が、会話を起動した側に消された」で、違いは Claude Code が残りを書いた時刻だけ。
29
+
30
+ Throughline はターンを user の発言から数える(`readLatestLogicalTurnCompletions`)。user の発言が1つも無い
31
+ transcript からは、どのターンも作れない。Claude Code は会話の最初に必ず user の行を書くので、
32
+ assistant の発言があって user の発言が無い transcript は、始まりを失った物に限られる。
33
+
34
+ 控えた transcript と同じ Stop の payload を、切り離した置き場で再生した。0.15.5 は同じ文面で失敗し、
35
+ `HOOK_PROCESS_TURN_FAILED` を1回数えた。
36
+
37
+ ## Decision
38
+
39
+ 1. flush barrier は、期限まで待っても完了が見えず、transcript に assistant の発言があって user の発言が
40
+ 1つも無い時、`transcript_head_lost` を返す。失敗にしない。
41
+ 2. 確認は期限に達した時だけ行う。遅れて書かれる user の行は、今までどおり期限まで待つ。
42
+ 3. user の発言が1つでも残っている時と、発言が1つも無いファイル(書き始め)は、今までどおり明示失敗して
43
+ `HOOK_PROCESS_TURN_FAILED` を数える(ADR 0012 Decision 4、ADR 0029 Decision 3)。
44
+ 4. `transcript_head_lost` の Stop は、ADR 0029 Decision 4 と同じ扱いにする。`backfill.log` に
45
+ `{"hook":"stop",…,"skipped":"transcript_head_lost"}` を1行残し、終了 code 0 で終わる。
46
+ DB・state file・完了受領・runtime error store には何も書かない。
47
+
48
+ ## Consequences
49
+
50
+ - 始まりを消された会話の最後のターンは保存されない。保存する依頼がどこにも無いので、今までも保存できていない。
51
+ 変わるのは、失敗に数えるかどうかだけ。
52
+ - 見送った Stop は `backfill.log` の `skipped` で数えられる。
53
+ - transcript の途中だけが失われ、user の発言が残っている時は、今までどおり失敗に数える。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -98,6 +98,20 @@ test('Claude Stopで、transcriptのファイルが無い会話は失敗に数
98
98
  skipped: 'transcript_absent',
99
99
  }]);
100
100
 
101
+ // ターンの途中でtranscriptが消され、応答の行だけでファイルが作り直された。依頼が残っていないので、失敗に数えない(ADR 0038)。
102
+ const headLostPath = join(root, 'head-lost.jsonl');
103
+ writeFileSync(headLostPath, JSON.stringify({ type: 'assistant',
104
+ message: { role: 'assistant', stop_reason: 'end_turn', content: [{ type: 'text', text: 'PROBE_OK' }] } }) + '\n');
105
+ const headLost = stop({
106
+ session_id: 'a1b2c3d4-0000-4000-8000-000000000003', transcript_path: headLostPath, cwd: root,
107
+ hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: 'PROBE_OK',
108
+ });
109
+ assert.equal(headLost.status, 0, headLost.stderr);
110
+ assert.equal(existsSync(storePath), false, 'a transcript that lost its head must not be counted as a hook failure');
111
+ assert.equal(existsSync(join(root, '.throughline', 'logs', 'hook-failures.log')), false);
112
+ assert.deepEqual([readLog('backfill.log').at(-1).session_id, readLog('backfill.log').at(-1).skipped],
113
+ ['a1b2c3d4-0000-4000-8000-000000000003', 'transcript_head_lost']);
114
+
101
115
  // ファイルはあるのに、Stopが渡した応答がそこに無い。保存できなかった会話なので、失敗のまま数える。
102
116
  const presentPath = join(root, 'present.jsonl');
103
117
  writeFileSync(presentPath, JSON.stringify({ type: 'user', message: { role: 'user', content: 'request' } }) + '\n');
@@ -33,6 +33,7 @@
33
33
  import { getDb } from './db.mjs';
34
34
  import {
35
35
  readRawEntries,
36
+ readTranscript,
36
37
  readLatestLogicalTurnCompletions,
37
38
  sliceCurrentTurnEntries,
38
39
  extractDetailBlocks,
@@ -60,6 +61,12 @@ export const CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS = 25;
60
61
 
61
62
  const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
62
63
 
64
+ /** transcript に assistant の発言だけがあり、user の発言が1つも無い(会話の始まりを失っている)。 */
65
+ function isTranscriptHeadLost(transcriptPath) {
66
+ const turns = readTranscript(transcriptPath);
67
+ return turns.length > 0 && !turns.some(turn => turn.role === 'user');
68
+ }
69
+
63
70
  /**
64
71
  * Claude Stop payloadのassistant identityがtranscriptへ永続化されるまで待つ。
65
72
  * markerは本文ソースにせず、transcript可視化のbarrierにだけ使う。
@@ -77,6 +84,10 @@ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
77
84
  * 期限まで待ってもtranscriptのファイル自体が無い時は、失敗にせず `transcript_absent` を返す
78
85
  * (ADR 0029)。Claude Codeを `--no-session-persistence` で起動すると、hookへ `transcript_path` は
79
86
  * 渡るが、ファイルは最後まで作られない。ファイルがあるのに完了が見えない時は、今までどおり失敗にする。
87
+ *
88
+ * 期限まで待って、transcriptにassistantの発言だけがありuserの発言が1つも無い時は、失敗にせず
89
+ * `transcript_head_lost` を返す(ADR 0038)。ターンの途中でtranscriptが消されると、Claude Codeは
90
+ * 残りの行だけでファイルを作り直す。そのターンの依頼はどこにも残っていない。
80
91
  */
81
92
  export async function waitForClaudeStopTranscriptFlush({
82
93
  transcriptPath,
@@ -87,6 +98,7 @@ export async function waitForClaudeStopTranscriptFlush({
87
98
  readCompletions = readLatestLogicalTurnCompletions,
88
99
  isTurnCaptured = () => true,
89
100
  transcriptExists = existsSync,
101
+ transcriptHeadLost = isTranscriptHeadLost,
90
102
  now = Date.now,
91
103
  wait = delay,
92
104
  }) {
@@ -115,8 +127,9 @@ export async function waitForClaudeStopTranscriptFlush({
115
127
  }
116
128
  const remaining = deadline - now();
117
129
  if (remaining <= 0) {
118
- if (typeof transcriptPath === 'string' && transcriptPath && !transcriptExists(transcriptPath)) {
119
- return { status: 'transcript_absent' };
130
+ if (typeof transcriptPath === 'string' && transcriptPath) {
131
+ if (!transcriptExists(transcriptPath)) return { status: 'transcript_absent' };
132
+ if (transcriptHeadLost(transcriptPath)) return { status: 'transcript_head_lost' };
120
133
  }
121
134
  throw new Error('Claude Stop transcript completion was not visible before deadline');
122
135
  }
@@ -279,8 +292,8 @@ async function processStop() {
279
292
  lastAssistantMessage: last_assistant_message,
280
293
  isTurnCaptured: (fragmentTurnNumbers) => isLogicalTurnCaptured(db, origin, fragmentTurnNumbers),
281
294
  });
282
- if (flush.status === 'transcript_absent') {
283
- // 保存する元が無い。失敗には数えず、どの会話のStopを見送ったかを端末内に残す(ADR 0029)。
295
+ if (flush.status === 'transcript_absent' || flush.status === 'transcript_head_lost') {
296
+ // 保存する元が無い。失敗には数えず、どの会話のStopを見送ったかを端末内に残す(ADR 0029・0038)。
284
297
  logBackfill({
285
298
  ts: new Date().toISOString(),
286
299
  hook: 'stop',
@@ -288,7 +301,7 @@ async function processStop() {
288
301
  target,
289
302
  origin,
290
303
  transcript_path,
291
- skipped: 'transcript_absent',
304
+ skipped: flush.status,
292
305
  });
293
306
  process.exit(0);
294
307
  }
@@ -274,6 +274,46 @@ test('Claude Stop flush barrierは、期限までtranscriptのファイルが無
274
274
  }
275
275
  });
276
276
 
277
+ test('Claude Stop flush barrierは、期限までtranscriptにuserの発言が1つも無い時だけtranscript_head_lostを返す', async () => {
278
+ const root = mkdtempSync(join(tmpdir(), 'throughline-stop-head-lost-'));
279
+ const transcriptPath = join(root, 'head-lost.jsonl');
280
+ let elapsed = 0;
281
+ const clock = { now: () => elapsed, wait: async (milliseconds) => { elapsed += milliseconds; } };
282
+ const line = (entry) => JSON.stringify(entry);
283
+ const assistant = (content) => ({ type: 'assistant', message: { role: 'assistant', stop_reason: 'end_turn', content } });
284
+ try {
285
+ // ターンの途中でtranscriptのフォルダが消され、Claude Codeが残りの行だけでファイルを作り直した形(Linux 2.1.292で実測)。
286
+ const tail = [
287
+ line(assistant([{ type: 'thinking', thinking: '' }])),
288
+ line(assistant([{ type: 'text', text: '最後の応答' }])),
289
+ line({ type: 'system', subtype: 'stop_hook_summary', hookCount: 3 }),
290
+ line({ type: 'ai-title', aiTitle: '題' }),
291
+ ];
292
+ writeFileSync(transcriptPath, tail.join('\n') + '\n', 'utf8');
293
+ assert.deepEqual(await waitForClaudeStopTranscriptFlush({
294
+ transcriptPath, lastAssistantMessage: '最後の応答', timeoutMs: 30, intervalMs: 10, ...clock,
295
+ }), { status: 'transcript_head_lost' });
296
+ assert.equal(elapsed, 30, 'a user line that is written late must still get the whole deadline');
297
+
298
+ // userの発言が残っているのに完了が見えない時は、失敗のまま(保存できたはずの会話)。
299
+ writeFileSync(transcriptPath,
300
+ [line({ type: 'user', message: { role: 'user', content: '依頼' } }), ...tail].join('\n') + '\n', 'utf8');
301
+ elapsed = 0;
302
+ await assert.rejects(waitForClaudeStopTranscriptFlush({
303
+ transcriptPath, lastAssistantMessage: '別の応答', timeoutMs: 30, intervalMs: 10, ...clock,
304
+ }), /not visible before deadline/);
305
+
306
+ // 発言が1つも無いファイル(書き始め)は、先頭を失ったとは言えない。失敗のまま。
307
+ writeFileSync(transcriptPath, line({ type: 'mode', mode: 'normal' }) + '\n', 'utf8');
308
+ elapsed = 0;
309
+ await assert.rejects(waitForClaudeStopTranscriptFlush({
310
+ transcriptPath, lastAssistantMessage: '最後の応答', timeoutMs: 30, intervalMs: 10, ...clock,
311
+ }), /not visible before deadline/);
312
+ } finally {
313
+ rmSync(root, { recursive: true, force: true });
314
+ }
315
+ });
316
+
277
317
  test('Claude Stop flush barrierはmarkerなし旧payloadをone-shot互換へ残す', async () => {
278
318
  let reads = 0;
279
319
  const result = await waitForClaudeStopTranscriptFlush({