throughline 0.12.4 → 0.12.6

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,32 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.12.6] — 2026-10-04
14
+
15
+ ### 修正
16
+
17
+ - Codex Stop hookで、会話の取り込みが済んだ後のL1要約backend(Codex CLI)の失敗を、`HOOK_CODEX_FAILED`(Codex hook processing failed・high)ではなく`L1_SUMMARIZER_BACKEND_FAILED`(L1 summarizer backend failed・warn)として数える([ADR 0028](docs/adr/0028-l1-summarizer-backend-failure-code.md))。Codex CLIが利用上限や認証切れで非0終了すると、取り込みは成功しているのにhook処理の失敗として記録されていた。hookの終了codeとstderrは変えない。要約は次のStopが同じturnからやり直す。
18
+ - 0.9.0と0.12.5の両方で、20 turnを超えるスレッドのStopがCodex CLIの失敗で`HOOK_CODEX_FAILED`を記録することを再現した。Macに残っていた1151回の最後の1回は、そのスレッドが利用上限に当たった時刻と一致する。1151回すべてがこの形だったことを示す記録は無い。
19
+
20
+ ### 変更
21
+
22
+ - `~/.throughline/logs/hook-failures.log`に、errorが持つ`reason`と、外部CLIの`stderr`の末尾(1000字まで)を足す。`Codex CLI summarizer failed: exit 1`だけでは、利用上限か認証切れかが分からないため。端末内にだけ残し、外へは送らない。
23
+
24
+ ### 試験
25
+
26
+ - Observerのcursorがprojectのpath・session id・本文を持たないことを確かめる試験が、偶然落ちることがあった。sha256の16進に目印の`a257`が現れると失敗し、Windowsではpathをそのまま正規表現にしていた。目印を16進に現れない文字列にし、値をそのまま探す形に直す。製品の動作は変えていない。
27
+
28
+ ## [0.12.5] — 2026-10-04
29
+
30
+ ### 修正
31
+
32
+ - Claudeの応答が空白や改行で始まる(終わる)turnで、Stop hookが2秒待って`HOOK_PROCESS_TURN_FAILED`で失敗していた不具合を直す。Claude CodeはStop payloadの`last_assistant_message`を`.trim()`して渡すが、transcriptにはtrimする前の本文が残る。flush barrierは完全一致で比べていたので、transcriptが出そろっていても一致しなかった。前後の空白を除いて比べる([ADR 0027](docs/adr/0027-stop-flush-barrier-trimmed-marker.md))。保存する本文はtranscriptのままで変えない。
33
+ - Macで記録された最後の`HOOK_PROCESS_TURN_FAILED`(2026-08-31)がこの形だった。その時のtranscriptでStopを再生すると、0.10.3・0.12.1・0.12.4のどれも失敗し、この版では成功する。Macに残る8月のtranscriptでは、空白で始まる応答のStopが96回あり、96回とも保存されていなかった。
34
+
35
+ ### 追加
36
+
37
+ - hookが失敗した時の理由を、端末内の`~/.throughline/logs/hook-failures.log`へ残す。1行のJSONで、項目は`ts`・`code`・`version`・`name`・`message`(1000字まで)。SessionStart・UserPromptSubmit・Stop・Codex hookが対象。runtime errorの収集・送信の設定とは独立に書き、外へは送らない。runtime error storeは定型codeと回数だけを持つので、これまでは失敗の理由が残らず、後から原因を追えなかった。
38
+
13
39
  ## [0.12.4] — 2026-10-04
14
40
 
15
41
  ### 修正
@@ -1577,7 +1603,9 @@ two attempts, instrument first instead of patching again.
1577
1603
 
1578
1604
  ---
1579
1605
 
1580
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.4...HEAD
1606
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.6...HEAD
1607
+ [0.12.6]: https://github.com/kitepon/Throughline/compare/v0.12.5...v0.12.6
1608
+ [0.12.5]: https://github.com/kitepon/Throughline/compare/v0.12.4...v0.12.5
1581
1609
  [0.12.4]: https://github.com/kitepon/Throughline/compare/v0.12.3...v0.12.4
1582
1610
  [0.12.3]: https://github.com/kitepon/Throughline/compare/v0.12.2...v0.12.3
1583
1611
  [0.12.2]: https://github.com/kitepon/Throughline/compare/v0.12.1...v0.12.2
@@ -237,6 +237,8 @@ switch (cmd) {
237
237
  if (code) {
238
238
  const { recordRuntimeErrorBestEffort } = await import('../src/runtime-error-store.mjs');
239
239
  recordRuntimeErrorBestEffort(code);
240
+ const { logHookFailure } = await import('../src/hook-failure-log.mjs');
241
+ logHookFailure(code, error);
240
242
  }
241
243
  throw error;
242
244
  }
@@ -17,6 +17,7 @@ turn後に現行parserで読むとlatest logical groupは1件だった。Claude
17
17
 
18
18
  1. Stop payloadに非空`last_assistant_message`がある場合、latest user groupの非junk assistant本文が
19
19
  その値と一致するまで短いbounded intervalでtranscriptを再読する。
20
+ (2026-10-04 [ADR 0027](0027-stop-flush-barrier-trimmed-marker.md)で、前後の空白を除いて比べることにした。Claude Codeはmarkerを`.trim()`して渡す。)
20
21
  2. `last_assistant_message`はcompletion identity/flush barrierにだけ使い、L2本文やreceipt digestの
21
22
  ソースにはしない。本文は従来どおりtranscriptからDBへcommitしたpairだけを使う。
22
23
  3. latest user groupを必須にし、過去の同文assistantや前turnを一致として採用しない。
@@ -66,6 +66,8 @@ none/low/medium/high/xhigh)のため none を下端とした。
66
66
  4. claude-primary の backend 順序を **codex-sidecar → Codex CLI (luna@low) → Haiku →
67
67
  raw L2** にする。各段の失敗理由は結果 (`sidecarReason` / `codexCliReason`) に記録
68
68
  する宣言済み fallback。codex-primary は従来どおり Codex CLI 一本で explicit error。
69
+ (2026-10-04 [ADR 0028](0028-l1-summarizer-backend-failure-code.md)で、この explicit error を runtime error store では
70
+ `L1_SUMMARIZER_BACKEND_FAILED` として数えることにした。hook の終了 code と stderr は変えていない。)
69
71
  (実測で Haiku は timeout 完全失敗があり、luna は同等品質・半分以下のレイテンシ・
70
72
  失敗ゼロのため、Haiku より先に置く。)
71
73
  5. sidecar `summarize-l1` preset のモデルは codex-sidecar(別repo)の所有。本 ADR では
@@ -0,0 +1,62 @@
1
+ # ADR 0027: flush barrier は marker と本文を前後の空白を除いて比べ、hook 失敗の理由を端末内に残す
2
+
3
+ 日付: 2026-10-04
4
+
5
+ ## Context
6
+
7
+ ADR 0012 の flush barrier は、Stop payload の `last_assistant_message` と transcript の本文が
8
+ 完全に一致するまで最大2秒待ち、一致しなければ `HOOK_PROCESS_TURN_FAILED` で明示失敗する。
9
+
10
+ Claude Code は `last_assistant_message` を、最後の assistant message の text block を改行でつないで
11
+ `.trim()` した値として渡す(Claude Code の実装で確認)。transcript には trim する前の本文が残る。
12
+ 本文が空白や改行で始まる(終わる)turn は、transcript が出そろっていても一致しない。
13
+
14
+ Mac の runtime error store には `HOOK_PROCESS_TURN_FAILED` が 679 回あり、最後は
15
+ 2026-08-31T13:16:06.049Z だった。その turn の transcript は次の並びだった。
16
+
17
+ ```text
18
+ 13:16:02.150 assistant の最終行(end_turn)。本文は半角空白で始まる
19
+ 13:16:04.195 stop_hook_summary(同期 hook の終わり。Stop hook の開始は 13:16:03.9 ごろ)
20
+ 13:16:06.049 HOOK_PROCESS_TURN_FAILED(開始から 2.1 秒後。backfill.log に行は無い)
21
+ ```
22
+
23
+ この transcript をその時点で切り、trim した marker で Stop を再生すると、0.10.3・0.12.1・0.12.4 の
24
+ どれも 2.1 秒後に `Claude Stop transcript completion was not visible before deadline` で失敗した。
25
+ Mac に残る 2026-08-02 以降の transcript では、本文が空白で始まる Stop が8月に 96 回あり、
26
+ 96 回とも backfill.log に対応する行が無かった。9月以降は、空白で始まる本文が1回も無い。
27
+
28
+ 原因を調べる時、失敗の理由はどこにも残っていなかった。runtime error store は外へ送れる形
29
+ (定型 code と回数)だけを持つ(ADR 0025)。Claude の Stop hook は `async: true` なので、
30
+ stderr は transcript の `stop_hook_summary` にも残らない。Mac の Codex hook 1151 回と
31
+ SessionStart 38 回、main-server の 2026-07-20 の Stop 4 回は、発生時刻と回数しか分からなかった。
32
+
33
+ ## Decision
34
+
35
+ 1. flush barrier は、marker と transcript の本文を、どちらも前後の空白を除いてから比べる。
36
+ latest group と、ADR 0026 の1つ前の group の両方に同じ比べ方を使う。
37
+ 2. 途中の空白は比べる対象に残す。前後の空白以外が違う本文は一致させない。
38
+ 3. 空白だけの marker は、marker が無い時(ADR 0012 Decision 5)と同じに扱う。
39
+ 4. 本文の正本は transcript のまま。DB と完了受領には trim しない本文を書く(ADR 0012 Decision 2)。
40
+ 5. hook が失敗した時、理由を `~/.throughline/logs/hook-failures.log` へ1行の JSON で追記する。
41
+ 項目は `ts`・`code`・`version`・`name`・`message`(1000 字まで)。
42
+ 6. このログは端末内にだけ置く。runtime error の収集・送信の設定とは独立に書き、外へは送らない。
43
+ runtime error store と report の形(ADR 0025)は変えない。
44
+ 7. ログの書き込みに失敗した時は stderr へ理由を出す。hook の終了コードは元の失敗のまま変えない。
45
+
46
+ ## Consequences
47
+
48
+ - 本文が空白や改行で始まる(終わる)turn の L2・完了受領・L3 が、同じ Stop で書かれる。
49
+ - 「過去の同文 answer を今回の完了と取り違えない」条件(ADR 0012 Decision 3、ADR 0026)は変わらない。
50
+ 前後の空白だけが違う2つの本文は同文として扱うが、どちらの条件も group の位置と捕捉済みかで決まる。
51
+ - 次に hook が失敗した時、時刻・code・版・理由が端末に残る。`runtime-errors snapshot` の回数と
52
+ 突き合わせて原因を追える。
53
+ - `message` には例外の文面が入る。path を含むことがあるので、ログを端末の外へ出す時は読んで確かめる。
54
+ - ログの大きさに上限は設けていない。失敗した時だけ1行増える。
55
+
56
+ ## 確認できていないこと
57
+
58
+ - Mac の 679 回のうち、空白が原因と確かめたのは transcript が残る 96 回と最後の1回。Stop 直後に次の入力が
59
+ 届いた回(ADR 0026、0.12.2 で修理)と、それ以外の回の内訳は、当時の理由が残っていないので分からない。
60
+ - Claude Code が1つの assistant message に text block を複数持たせた時、marker は block を改行でつなぎ、
61
+ Throughline は block をそのままつなぐ。transcript では1行に1 block で書かれていて、複数 block の行は
62
+ 確認していない。
@@ -0,0 +1,59 @@
1
+ # ADR 0028: Codex Stop の L1 要約 backend の失敗は、hook 処理の失敗と別の code で数える
2
+
3
+ 日付: 2026-10-04
4
+
5
+ ## Context
6
+
7
+ Codex Stop hook は、rollout を DB へ取り込んで commit した後、bodies が L2 window(20 turn)を超えていれば、
8
+ 最古の未要約 turn を Codex CLI(`codex exec`)で L1 に要約する。codex-primary の要約は Codex CLI 一本で、
9
+ CLI の失敗は explicit error にする(ADR 0015 Decision 4)。
10
+
11
+ この error は hook の一番外の catch まで届き、`HOOK_CODEX_FAILED`(`Throughline Codex hook processing failed`、
12
+ severity `high`)として runtime error store に数えられていた。
13
+
14
+ Mac の runtime error store には `HOOK_CODEX_FAILED` が 1151 回あった(2026-07-13 〜 2026-08-07、最後の版は 0.9.0)。
15
+ 最後の1回は 2026-08-07T06:23:58.298Z で、その Codex スレッドは同じ秒に次の turn が
16
+ `You've hit your usage limit` で終わっている。当時の stderr はどこにも残っていない。
17
+
18
+ 0.9.0 と 0.12.5 の両方で、次を再現した。20 turn を超えるスレッドに Stop を送り、Codex CLI が非0で終わると、
19
+ 取り込みは成功したまま hook が `Codex CLI summarizer failed: exit N`(reason `codex_cli_failed`)で失敗し、
20
+ `HOOK_CODEX_FAILED` が1回増える。UserPromptSubmit と PostToolUse は要約をしないので失敗しない。
21
+
22
+ `HOOK_CODEX_FAILED` という code からは、取り込みが壊れたのか、外部 CLI が使えなかっただけなのかを区別できない。
23
+ 後者は Codex 側の利用上限・認証切れ・model の失敗で起き、Throughline の修理では直らない。
24
+
25
+ ## Decision
26
+
27
+ 1. Codex hook の失敗のうち、L1 要約 backend の失敗だけを `L1_SUMMARIZER_BACKEND_FAILED` として数える。
28
+ component は `codex_l1_summarizer`、template は `Throughline L1 summarizer backend (Codex CLI) failed`、
29
+ severity は `warn`。
30
+ 2. 対象は `source` が `codex-cli` で、`reason` が `codex_cli_failed`(非0終了・timeout・起動失敗)か
31
+ `empty_output` の error だけ。`missing_project_path` と `recursion_guard` は製品側の誤りなので、
32
+ `HOOK_CODEX_FAILED` のままにする。要約以外の失敗も `HOOK_CODEX_FAILED` のまま。
33
+ 3. hook の終了 code(1)と stderr(`[codex-hook] Codex CLI summarizer failed: exit N`)は変えない。
34
+ ADR 0015 の explicit error 契約と、失敗を隠さない原則はそのまま。変えるのは数える code だけ。
35
+ 4. `hook-failures.log`(ADR 0027)に、error が持つ `reason` と、外部 CLI の `stderr` の末尾(1000字まで)を足す。
36
+ `Codex CLI summarizer failed: exit 1` だけでは、利用上限なのか認証切れなのかが分からないため。
37
+ このログは端末内にだけ残し、外へは送らない。runtime error store には今までどおり定型 code と回数だけを入れる。
38
+ 5. severity を `warn` にする理由: 会話の取り込みは commit 済みで、記憶は失われていない。要約は次の Stop が
39
+ 同じ turn からやり直す。L1 が無い間、recall は L2 の本文を返す(ADR 0016)。
40
+ BugHub が受ける severity は `fatal`・`high`・`warn`・`info` の4つで、他の値は report 全体が 422 になる
41
+ (2026-10-04、BugHub の `PRODUCT_REPORTING.md` と `factory-report-v1.schema.json` をBugHubの担当が確認)。
42
+
43
+ ## Consequences
44
+
45
+ - 外部 CLI が使えない端末は、BugHub で「L1 summarizer backend failed」(warn、日次のまとめ)として見える。
46
+ 「Codex hook processing failed」(high、即時通知)は、取り込みや hook 処理そのものの失敗だけになる。
47
+ - fingerprint は code ごとに決まるので、新しい code は BugHub で新しい issue になる。過去の `HOOK_CODEX_FAILED` の
48
+ 回数は移らない。
49
+ - runtime error store は、知らない code を持つ記録を schema invalid として拒否する。この版で
50
+ `L1_SUMMARIZER_BACKEND_FAILED` を記録した端末を 0.12.5 以前へ戻すと、store を読めなくなる。
51
+ - Claude Stop は対象外。claude-primary の要約は Codex CLI → Haiku → raw L2 の宣言済み fallback で、
52
+ backend の失敗では throw しない。
53
+
54
+ ## 確認できていないこと
55
+
56
+ - Mac の 1151 回がすべてこの形だったことを示す記録は無い。確認できたのは、同じ code を同じ版で再現できたことと、
57
+ 最後の1回が利用上限に当たった時刻と一致することだけ。外した仮説は、製品の試験からの漏れ、rollout の大きさ、
58
+ 入力の形、同時実行、スレッド全体の順次再生。
59
+ - 要約が失敗し続ける端末では、Codex の Stop hook が毎回 exit 1 になる。Codex 側の表示への影響は調べていない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -1,4 +1,5 @@
1
1
  import { recordRuntimeErrorBestEffort } from '../runtime-error-store.mjs';
2
+ import { logHookFailure } from '../hook-failure-log.mjs';
2
3
 
3
4
  function parseArgs(argv) {
4
5
  const out = {
@@ -359,6 +360,22 @@ async function runCodexContextRefreshInstructionHook({
359
360
  };
360
361
  }
361
362
 
363
+ const L1_SUMMARIZER_BACKEND_REASONS = new Set(['codex_cli_failed', 'empty_output']);
364
+
365
+ /**
366
+ * Codex hook が失敗した時に記録する runtime error の code を決める(ADR 0028)。
367
+ * L1 要約の backend(Codex CLI)が非0で終わった、または空を返した失敗は、取り込みの後に起きる
368
+ * 外部 CLI の失敗なので、hook 処理の失敗とは別の code で数える。hook の終了 code と stderr は変えない。
369
+ * @param {unknown} error
370
+ * @returns {'HOOK_CODEX_FAILED' | 'L1_SUMMARIZER_BACKEND_FAILED'}
371
+ */
372
+ export function codexHookFailureCode(error) {
373
+ if (error?.source === 'codex-cli' && L1_SUMMARIZER_BACKEND_REASONS.has(error?.reason)) {
374
+ return 'L1_SUMMARIZER_BACKEND_FAILED';
375
+ }
376
+ return 'HOOK_CODEX_FAILED';
377
+ }
378
+
362
379
  export async function run(argv = []) {
363
380
  suppressExperimentalWarnings();
364
381
  let parsed;
@@ -368,6 +385,7 @@ export async function run(argv = []) {
368
385
  payload = parsePayload(await readStdin());
369
386
  } catch (err) {
370
387
  recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
388
+ logHookFailure('HOOK_CODEX_FAILED', err);
371
389
  const msg = err instanceof Error ? err.message : 'unknown';
372
390
  process.stderr.write(`[codex-hook] ${msg}\n`);
373
391
  process.exit(1);
@@ -381,6 +399,7 @@ export async function run(argv = []) {
381
399
  result = await requestCodexAutoHandoff({ payload });
382
400
  } catch (error) {
383
401
  recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
402
+ logHookFailure('HOOK_CODEX_FAILED', error);
384
403
  const code = typeof error.code === 'string' ? error.code : 'handoff_request_failed';
385
404
  result = { status: 'failed', continue: false,
386
405
  stopReason: `Throughline自動継続が失敗しました: ${code}。記憶の圧縮を停止します。` };
@@ -414,7 +433,9 @@ export async function run(argv = []) {
414
433
  // continue=falseは、明示した失敗理由を含めてhostへ停止を届けた返答。背景処理の成否は引き継ぎ状態へ記録する。
415
434
  process.exit(result.continue === false || result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
416
435
  } catch (err) {
417
- recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
436
+ const code = codexHookFailureCode(err);
437
+ recordRuntimeErrorBestEffort(code, { env: process.env });
438
+ logHookFailure(code, err);
418
439
  const msg = err instanceof Error ? err.message : 'unknown';
419
440
  if (parsed.json) {
420
441
  process.stdout.write(
@@ -6,6 +6,7 @@ import { test } from 'node:test';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
7
 
8
8
  import {
9
+ codexHookFailureCode,
9
10
  runCodexPostToolUseHook,
10
11
  runCodexStopHook,
11
12
  runCodexUserPromptSubmitHook,
@@ -497,6 +498,19 @@ test('codex-hook stop skips cleanly when Codex thread id is unavailable', async
497
498
  }
498
499
  });
499
500
 
501
+ test('codexHookFailureCodeは、L1要約backendの失敗だけを別のcodeにする', () => {
502
+ const backend = (reason) => Object.assign(new Error('x'), { source: 'codex-cli', reason });
503
+ assert.equal(codexHookFailureCode(backend('codex_cli_failed')), 'L1_SUMMARIZER_BACKEND_FAILED');
504
+ assert.equal(codexHookFailureCode(backend('empty_output')), 'L1_SUMMARIZER_BACKEND_FAILED');
505
+ // 製品側の誤り(projectPath 欠落・再帰)と、要約以外の失敗は hook 処理の失敗のまま。
506
+ assert.equal(codexHookFailureCode(backend('missing_project_path')), 'HOOK_CODEX_FAILED');
507
+ assert.equal(codexHookFailureCode(backend('recursion_guard')), 'HOOK_CODEX_FAILED');
508
+ assert.equal(codexHookFailureCode(Object.assign(new Error('x'), { reason: 'codex_cli_failed' })), 'HOOK_CODEX_FAILED');
509
+ assert.equal(codexHookFailureCode(new Error('database is locked')), 'HOOK_CODEX_FAILED');
510
+ assert.equal(codexHookFailureCode('thrown string'), 'HOOK_CODEX_FAILED');
511
+ assert.equal(codexHookFailureCode(null), 'HOOK_CODEX_FAILED');
512
+ });
513
+
500
514
  test('codexHomeFromTranscriptPath infers CODEX_HOME from rollout path', () => {
501
515
  const path = '/tmp/codex-home/sessions/2026/05/06/rollout-2026-05-06T09-40-50-id.jsonl';
502
516
  assert.equal(_internal.codexHomeFromTranscriptPath(path), '/tmp/codex-home');
@@ -918,6 +918,74 @@ test('process-turn subprocess captures the completed turn when the next user ent
918
918
  }
919
919
  });
920
920
 
921
+ test('process-turn subprocess captures a turn whose transcript text starts with whitespace', () => {
922
+ const home = makeTempHome();
923
+ const project = makeTempProject();
924
+ const transcriptPath = join(project, 'transcript.jsonl');
925
+ const text = (role, value) => ({ type: role, message: { role, content: [{ type: 'text', text: value }] } });
926
+ try {
927
+ // Claude Codeは `last_assistant_message` を `.trim()` して渡す。transcriptの本文はそのまま保存する。
928
+ writeFileSync(transcriptPath, [
929
+ text('user', 'request'),
930
+ text('assistant', ' answer that starts with a space'),
931
+ ].map((entry) => JSON.stringify(entry)).join('\n'), 'utf8');
932
+ const result = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), 'process-turn'], {
933
+ home,
934
+ cwd: project,
935
+ input: JSON.stringify({
936
+ session_id: 'leading-space-session',
937
+ cwd: project,
938
+ transcript_path: transcriptPath,
939
+ last_assistant_message: 'answer that starts with a space',
940
+ }),
941
+ });
942
+ assert.equal(result.status, 0, result.stderr);
943
+ const db = openDb(home);
944
+ assert.deepEqual(
945
+ db.prepare('SELECT role, text FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
946
+ [{ role: 'assistant', text: ' answer that starts with a space' }, { role: 'user', text: 'request' }],
947
+ );
948
+ db.close();
949
+ assert.equal(readCompletedTurnReceiptSnapshot({ projectPath: project, env: childEnv(home) }).receipts.length, 1);
950
+ assert.equal(existsSync(join(home, '.throughline', 'logs', 'hook-failures.log')), false);
951
+ } finally {
952
+ rmSync(project, { recursive: true, force: true });
953
+ rmSync(home, { recursive: true, force: true });
954
+ }
955
+ });
956
+
957
+ test('a failed hook leaves its reason in the local hook failure log', () => {
958
+ const home = makeTempHome();
959
+ const project = makeTempProject();
960
+ const failures = () => readFileSync(join(home, '.throughline', 'logs', 'hook-failures.log'), 'utf8')
961
+ .trim().split('\n').map((line) => JSON.parse(line));
962
+ try {
963
+ // 公開CLI経由(hookが実際に呼ぶ入口)と、moduleの直接起動の両方で残す。
964
+ const viaCli = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), 'process-turn'], {
965
+ home, cwd: project, input: JSON.stringify({ cwd: project }),
966
+ });
967
+ assert.notEqual(viaCli.status, 0);
968
+ const direct = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
969
+ home, cwd: project, input: JSON.stringify({ cwd: project }),
970
+ });
971
+ assert.notEqual(direct.status, 0);
972
+
973
+ const entries = failures();
974
+ assert.deepEqual(entries.map((entry) => [entry.code, entry.name, entry.message]), [
975
+ ['HOOK_PROCESS_TURN_FAILED', 'Error', 'Missing session_id in Stop payload'],
976
+ ['HOOK_SESSION_START_FAILED', 'Error', 'Missing session_id in SessionStart payload'],
977
+ ]);
978
+ for (const entry of entries) {
979
+ assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message']);
980
+ assert.match(entry.ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
981
+ assert.match(entry.version, /^\d+\.\d+\.\d+$/);
982
+ }
983
+ } finally {
984
+ rmSync(project, { recursive: true, force: true });
985
+ rmSync(home, { recursive: true, force: true });
986
+ }
987
+ });
988
+
921
989
  test('first prompt backfills a derived predecessor transcript without a state file', {
922
990
  skip: process.platform === 'win32' ? 'Windowsはstate file transcriptPath fallback契約' : undefined,
923
991
  }, () => {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * hook-failure-log.mjs — hook 失敗の理由ログ (~/.throughline/logs/hook-failures.log)
3
+ *
4
+ * runtime error store は外へ送れる形(定型 code と回数)だけを持つ (ADR 0025)。失敗した時の
5
+ * 例外の文面はどこにも残らず、後から原因を追えなかった(2026-10-04、Mac と main-server の
6
+ * 7〜8月の失敗)。このログは理由を端末内にだけ残す。runtime error の収集・送信の設定とは
7
+ * 独立で、外へは送らない。
8
+ * 書き込み失敗は stderr に出す(黙って握りつぶさない)。
9
+ */
10
+
11
+ import { appendFileSync, mkdirSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const PACKAGE_VERSION = require('../package.json').version;
18
+
19
+ export const HOOK_FAILURE_MESSAGE_LIMIT = 1000;
20
+
21
+ export function hookFailureLogPath({ home = homedir() } = {}) {
22
+ return join(home, '.throughline', 'logs', 'hook-failures.log');
23
+ }
24
+
25
+ /**
26
+ * @param {string} code runtime error の定型 code(例: HOOK_PROCESS_TURN_FAILED)
27
+ * @param {unknown} error 失敗の原因
28
+ * @param {{ home?: string, now?: number, stderr?: { write: (s: string) => unknown } }} [options]
29
+ */
30
+ export function logHookFailure(code, error, { home, now = Date.now(), stderr = process.stderr } = {}) {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ const entry = {
33
+ ts: new Date(now).toISOString(),
34
+ code,
35
+ version: PACKAGE_VERSION,
36
+ name: error instanceof Error ? error.name : typeof error,
37
+ message: message.slice(0, HOOK_FAILURE_MESSAGE_LIMIT),
38
+ };
39
+ // 外部 CLI の失敗は、message が終了 code だけになる。理由(reason)と CLI の stderr の末尾も残す
40
+ // (CLI は先頭に起動情報を出し、失敗の理由は末尾に出す)。
41
+ if (typeof error?.reason === 'string' && error.reason) entry.reason = error.reason;
42
+ if (typeof error?.stderr === 'string' && error.stderr) {
43
+ entry.stderr = error.stderr.slice(-HOOK_FAILURE_MESSAGE_LIMIT);
44
+ }
45
+ const path = hookFailureLogPath(home === undefined ? {} : { home });
46
+ try {
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
49
+ } catch (err) {
50
+ const msg = err instanceof Error ? err.message : 'unknown';
51
+ stderr.write(`[hook-failure-log] ${msg}\n`);
52
+ }
53
+ }
@@ -0,0 +1,67 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { HOOK_FAILURE_MESSAGE_LIMIT, hookFailureLogPath, logHookFailure } from './hook-failure-log.mjs';
7
+
8
+ test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
9
+ const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
10
+ try {
11
+ logHookFailure('HOOK_PROCESS_TURN_FAILED', new TypeError('first reason'), { home, now: Date.UTC(2026, 9, 4, 0, 0, 0, 5) });
12
+ logHookFailure('HOOK_CODEX_FAILED', 'thrown string', { home, now: Date.UTC(2026, 9, 4, 0, 0, 1) });
13
+ const lines = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
14
+ assert.equal(lines.length, 2);
15
+ assert.deepEqual({ ...lines[0], version: 'x' }, {
16
+ ts: '2026-10-04T00:00:00.005Z', code: 'HOOK_PROCESS_TURN_FAILED', version: 'x', name: 'TypeError', message: 'first reason',
17
+ });
18
+ assert.deepEqual({ ...lines[1], version: 'x' }, {
19
+ ts: '2026-10-04T00:00:01.000Z', code: 'HOOK_CODEX_FAILED', version: 'x', name: 'string', message: 'thrown string',
20
+ });
21
+ } finally {
22
+ rmSync(home, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ test('logHookFailureは、外部CLIの失敗の理由とstderrの末尾を残す', () => {
27
+ const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
28
+ try {
29
+ const err = new Error('Codex CLI summarizer failed: exit 1');
30
+ err.reason = 'codex_cli_failed';
31
+ err.stderr = `${'banner '.repeat(300)}ERROR: usage limit`;
32
+ logHookFailure('L1_SUMMARIZER_BACKEND_FAILED', err, { home });
33
+ const [entry] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
34
+ assert.equal(entry.code, 'L1_SUMMARIZER_BACKEND_FAILED');
35
+ assert.equal(entry.message, 'Codex CLI summarizer failed: exit 1');
36
+ assert.equal(entry.reason, 'codex_cli_failed');
37
+ assert.equal(entry.stderr.length, HOOK_FAILURE_MESSAGE_LIMIT);
38
+ assert.match(entry.stderr, /ERROR: usage limit$/);
39
+ } finally {
40
+ rmSync(home, { recursive: true, force: true });
41
+ }
42
+ });
43
+
44
+ test('logHookFailureは、長い文面を上限で切る', () => {
45
+ const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
46
+ try {
47
+ logHookFailure('HOOK_PROMPT_SUBMIT_FAILED', new Error('x'.repeat(HOOK_FAILURE_MESSAGE_LIMIT + 50)), { home });
48
+ const [entry] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
49
+ assert.equal(entry.message.length, HOOK_FAILURE_MESSAGE_LIMIT);
50
+ } finally {
51
+ rmSync(home, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('logHookFailureは、書けない時にstderrへ理由を出し、例外を投げない', () => {
56
+ const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
57
+ try {
58
+ // `.throughline` をfileにして、logs directoryを作れなくする。
59
+ writeFileSync(join(home, '.throughline'), 'not a directory', 'utf8');
60
+ const written = [];
61
+ logHookFailure('HOOK_SESSION_START_FAILED', new Error('reason'), { home, stderr: { write: (s) => written.push(s) } });
62
+ assert.equal(written.length, 1);
63
+ assert.match(written[0], /^\[hook-failure-log\] /);
64
+ } finally {
65
+ rmSync(home, { recursive: true, force: true });
66
+ }
67
+ });
@@ -126,7 +126,7 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
126
126
  try {
127
127
  const options = box.receiptOptions;
128
128
  seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: options, targetSessionId: 'claude-session' });
129
- writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: 'o257', userBody: 'u257', assistantBody: 'a257', completedAt: 257 }, options);
129
+ writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: 'o257', userBody: 'user-body-257', assistantBody: 'assistant-body-257', completedAt: 257 }, options);
130
130
  const latest = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
131
131
  const decoded = decodeObserverCursor(latest.throughCursor);
132
132
  const beforeFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
@@ -137,7 +137,12 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
137
137
  const changedPrefix = encodeObserverCursor({ ...decoded, prefix_sha256: 'f'.repeat(64) });
138
138
  assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: changedPrefix, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
139
139
  assert.throws(() => encodeObserverCursor({ ...decoded, length: 0 }), /cursor invalid/);
140
- assert.doesNotMatch(JSON.stringify(decoded), new RegExp(`${box.project}|claude-session|u257|a257`));
140
+ // cursor は project の path・session id・本文を持たない。目印は16進に現れない文字列にし、正規表現を使わず
141
+ // 値をそのまま探す(sha256 の16進に `a257` が偶然現れる、Windows の path が正規表現として壊れる、の2つを避ける)。
142
+ const cursorValues = Object.values(decoded).map(String).join('\n');
143
+ for (const secret of [box.project, 'claude-session', 'user-body-257', 'assistant-body-257']) {
144
+ assert.equal(cursorValues.includes(secret), false, secret);
145
+ }
141
146
  const id = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
142
147
  writeRollout(box.home, box.project, id, completeEvents('2026-07-15T00:03:00.000Z'));
143
148
  const switched = resolveObserverTurnFeed({ projectPath: box.project, cursor: latest.throughCursor, receiptOptions: options, codexHome: box.home });
@@ -40,6 +40,7 @@ import { join, dirname } from 'node:path';
40
40
  import { homedir } from 'node:os';
41
41
  import { pathToFileURL } from 'node:url';
42
42
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
43
+ import { logHookFailure } from './hook-failure-log.mjs';
43
44
  import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
44
45
 
45
46
  // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
@@ -325,6 +326,7 @@ async function maybeRunPromptSpike({ payload, sessionId, projectPath }) {
325
326
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
326
327
  run().catch((err) => {
327
328
  recordRuntimeErrorBestEffort('HOOK_PROMPT_SUBMIT_FAILED');
329
+ logHookFailure('HOOK_PROMPT_SUBMIT_FAILED', err);
328
330
  const msg = err instanceof Error ? err.message : 'unknown';
329
331
  process.stderr.write(`[prompt-submit] error: ${msg}\n`);
330
332
  process.exit(1);
@@ -1,7 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { execFileSync, spawnSync } from 'node:child_process';
4
- import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { DatabaseSync } from 'node:sqlite';
5
6
  import { tmpdir } from 'node:os';
6
7
  import { dirname, join } from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
@@ -69,6 +70,77 @@ test('top-level hook owners record one fixed aggregate per failure without repla
69
70
  assert.equal(store.records.find((record) => record.error_code === 'HOOK_PROCESS_TURN_FAILED').count, 2);
70
71
  });
71
72
 
73
+ function makeBin(dir, name, body) {
74
+ const script = join(dir, `${name}.mjs`);
75
+ writeFileSync(script, body);
76
+ if (process.platform === 'win32') {
77
+ const command = join(dir, `${name}.cmd`);
78
+ writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(script)} %*\r\n`);
79
+ writeFileSync(join(dir, `${name}.ps1`), `& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args\nexit $LASTEXITCODE\n`);
80
+ return command;
81
+ }
82
+ const command = join(dir, name);
83
+ writeFileSync(command, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
84
+ chmodSync(command, 0o755);
85
+ return command;
86
+ }
87
+
88
+ test('Codex StopでL1要約backendが失敗した時、取り込みは残り、別のcodeで1回数える', () => {
89
+ const { root, env } = createEnabledEnvironment('throughline-runtime-hook-summarizer-');
90
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
91
+ const project = join(root, 'project');
92
+ const codexHome = join(root, 'codex');
93
+ const rolloutDir = join(codexHome, 'sessions', '2026', '05', '06');
94
+ mkdirSync(project, { recursive: true });
95
+ mkdirSync(rolloutDir, { recursive: true });
96
+ const at = '2026-05-06T00:41:00.000Z';
97
+ const event = (type, payload = {}) => ({ timestamp: at, type: 'event_msg', payload: { type, ...payload } });
98
+ const rows = [{ timestamp: at, type: 'session_meta',
99
+ payload: { id: threadId, timestamp: at, cwd: project, source: 'vscode', cli_version: '0.128.0-alpha.1' } }];
100
+ // L2 window(20 turn)を超えると、Stop は最古の turn を Codex CLI で要約する。
101
+ for (let turn = 0; turn < 22; turn += 1) {
102
+ rows.push(event('user_message', { message: `request ${turn}` }), event('task_started'),
103
+ { timestamp: at, type: 'turn_context', payload: { model: 'gpt-5.5', cwd: project } },
104
+ event('agent_message', { message: `answer ${turn}` }), event('task_complete'));
105
+ }
106
+ const rolloutPath = join(rolloutDir, `rollout-2026-05-06T09-40-50-${threadId}.jsonl`);
107
+ writeFileSync(rolloutPath, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
108
+ const binDir = join(root, 'bin');
109
+ mkdirSync(binDir, { recursive: true });
110
+ const codex = makeBin(binDir, 'codex', "process.stderr.write('ERROR: usage limit reached\\n');\nprocess.exit(42);\n");
111
+
112
+ const result = spawnSync(process.execPath, [BIN, 'codex-hook', 'stop'], {
113
+ env: { ...env, CODEX_HOME: codexHome, THROUGHLINE_CODEX_CLI_BIN: codex },
114
+ input: JSON.stringify({ session_id: threadId, transcript_path: rolloutPath, cwd: project }),
115
+ encoding: 'utf8',
116
+ });
117
+
118
+ // hook は明示して失敗する(終了 code と stderr は変えない)。
119
+ assert.equal(result.status, 1, result.stderr);
120
+ assert.match(result.stderr, /\[codex-hook\] Codex CLI summarizer failed: exit 42/);
121
+
122
+ const store = JSON.parse(readFileSync(defaultRuntimeErrorStorePath(env), 'utf8'));
123
+ assert.deepEqual(store.records.map((record) => [record.error_code, record.component, record.severity, record.count]), [
124
+ ['L1_SUMMARIZER_BACKEND_FAILED', 'codex_l1_summarizer', 'warn', 1],
125
+ ]);
126
+
127
+ // 取り込みは要約の前に commit 済み。
128
+ const db = new DatabaseSync(join(root, '.throughline', 'throughline.db'));
129
+ try {
130
+ const { n } = db.prepare("SELECT COUNT(*) AS n FROM bodies WHERE session_id = ? AND role = 'assistant'")
131
+ .get(`codex:${threadId}`);
132
+ assert.equal(n, 22);
133
+ } finally {
134
+ db.close();
135
+ }
136
+
137
+ const [entry] = readFileSync(join(root, '.throughline', 'logs', 'hook-failures.log'), 'utf8')
138
+ .trim().split('\n').map((line) => JSON.parse(line));
139
+ assert.equal(entry.code, 'L1_SUMMARIZER_BACKEND_FAILED');
140
+ assert.equal(entry.reason, 'codex_cli_failed');
141
+ assert.match(entry.stderr, /usage limit reached/);
142
+ });
143
+
72
144
  test('store failure preserves product failure and emits only fixed storage diagnostic', () => {
73
145
  const { env } = createEnabledEnvironment('throughline-runtime-hook-store-fail-');
74
146
  const storePath = defaultRuntimeErrorStorePath(env);
@@ -52,6 +52,13 @@ const DEFINITIONS = Object.freeze({
52
52
  severity: 'high',
53
53
  template: 'Throughline Codex hook processing failed',
54
54
  }),
55
+ // Codex Stop の取り込みは済み、L1 要約の backend(Codex CLI)だけが失敗した(ADR 0028)。
56
+ L1_SUMMARIZER_BACKEND_FAILED: Object.freeze({
57
+ component: 'codex_l1_summarizer',
58
+ // BugHub が受ける severity は fatal・high・warn・info だけ。他の値は report 全体が 422 になる。
59
+ severity: 'warn',
60
+ template: 'Throughline L1 summarizer backend (Codex CLI) failed',
61
+ }),
55
62
  });
56
63
 
57
64
  export function defaultRuntimeErrorConfigPath(env = process.env) {
@@ -32,6 +32,7 @@ import { logDecision } from './decision-log.mjs';
32
32
  import { existsSync } from 'node:fs';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
+ import { logHookFailure } from './hook-failure-log.mjs';
35
36
  import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
36
37
  import { executeFirstPromptHandoff } from './handoff-executor.mjs';
37
38
 
@@ -190,6 +191,7 @@ export async function run() {
190
191
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
191
192
  run().catch((err) => {
192
193
  recordRuntimeErrorBestEffort('HOOK_SESSION_START_FAILED');
194
+ logHookFailure('HOOK_SESSION_START_FAILED', err);
193
195
  process.stderr.write(`[session-start] error: ${err.message}\n`);
194
196
  process.exit(1);
195
197
  });
@@ -45,6 +45,7 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
45
45
  import { readLatestUsage } from './transcript-usage.mjs';
46
46
  import { pathToFileURL } from 'node:url';
47
47
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
48
+ import { logHookFailure } from './hook-failure-log.mjs';
48
49
  import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
49
50
  import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
50
51
 
@@ -65,6 +66,9 @@ const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
65
66
  * - latest groupにassistant断片がまだ無い(次のturnは本文を書いていない)
66
67
  * - 1つ前のgroupの本文がmarkerと一致する
67
68
  * - そのturnがまだDBに捕捉されていない(捕捉済みの同文answerは、今回のStopの完了ではない)
69
+ *
70
+ * markerとtranscriptの本文は、前後の空白を除いて比べる(ADR 0027)。Claude Codeはmarkerを
71
+ * `.trim()` して渡すので、本文が空白や改行で始まる(終わる)turnは、そのままでは一致しない。
68
72
  */
69
73
  export async function waitForClaudeStopTranscriptFlush({
70
74
  transcriptPath,
@@ -77,7 +81,7 @@ export async function waitForClaudeStopTranscriptFlush({
77
81
  now = Date.now,
78
82
  wait = delay,
79
83
  }) {
80
- if (typeof lastAssistantMessage !== 'string' || lastAssistantMessage.length === 0) {
84
+ if (typeof lastAssistantMessage !== 'string' || lastAssistantMessage.trim().length === 0) {
81
85
  return { status: 'marker_unavailable' };
82
86
  }
83
87
  const read = readCompletion
@@ -88,11 +92,14 @@ export async function waitForClaudeStopTranscriptFlush({
88
92
  userTurnNumber: completion.userTurnNumber,
89
93
  assistantTurnNumber: completion.assistantTurnNumber,
90
94
  });
95
+ const marker = lastAssistantMessage.trim();
96
+ const matchesMarker = (completion) =>
97
+ typeof completion?.assistantContent === 'string' && completion.assistantContent.trim() === marker;
91
98
  const deadline = now() + timeoutMs;
92
99
  for (;;) {
93
100
  const { latest, previous } = read() ?? { latest: null, previous: null };
94
- if (latest?.assistantContent === lastAssistantMessage) return ready(latest);
95
- if (previous?.assistantContent === lastAssistantMessage &&
101
+ if (matchesMarker(latest)) return ready(latest);
102
+ if (matchesMarker(previous) &&
96
103
  latest?.fragmentTurnNumbers?.length === 0 &&
97
104
  !isTurnCaptured(previous.fragmentTurnNumbers)) {
98
105
  return ready(previous);
@@ -418,6 +425,7 @@ export async function run() {
418
425
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
419
426
  run().catch((err) => {
420
427
  recordRuntimeErrorBestEffort('HOOK_PROCESS_TURN_FAILED');
428
+ logHookFailure('HOOK_PROCESS_TURN_FAILED', err);
421
429
  const msg = err instanceof Error ? err.message : String(err);
422
430
  process.stderr.write(`[turn-processor] error: ${msg}\n`);
423
431
  process.exit(1);
@@ -105,6 +105,53 @@ test('Claude Stop flush barrierはlatest userの遅延assistantを待ち、過
105
105
  }
106
106
  });
107
107
 
108
+ test('Claude Stop flush barrierは、前後の空白を除いてmarkerと本文を比べる', async () => {
109
+ const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-trim-'));
110
+ const transcriptPath = join(root, 'transcript.jsonl');
111
+ const text = (role, value) => ({ type: role, message: { role, content: [{ type: 'text', text: value }] } });
112
+ let elapsed = 0;
113
+ const clock = { now: () => elapsed, wait: async (milliseconds) => { elapsed += milliseconds; } };
114
+ const write = (entries) => writeFileSync(
115
+ transcriptPath, entries.map((entry) => JSON.stringify(entry)).join('\n'), 'utf8');
116
+ try {
117
+ // 実測(2026-08-31、Mac): 本文が空白で始まるturn。Claude Codeはmarkerを `.trim()` して渡す。
118
+ write([text('user', 'request'), text('assistant', ' answer with a leading space')]);
119
+ assert.deepEqual(await waitForClaudeStopTranscriptFlush({
120
+ transcriptPath, lastAssistantMessage: 'answer with a leading space', timeoutMs: 100, intervalMs: 10, ...clock,
121
+ }), { status: 'ready', userTurnNumber: 0, assistantTurnNumber: 1 });
122
+ assert.equal(elapsed, 0, 'a whitespace-only difference must not wait for the deadline');
123
+
124
+ write([text('user', 'request'), text('assistant', '\n\nanswer with newlines\n')]);
125
+ assert.deepEqual(await waitForClaudeStopTranscriptFlush({
126
+ transcriptPath, lastAssistantMessage: 'answer with newlines', timeoutMs: 100, intervalMs: 10, ...clock,
127
+ }), { status: 'ready', userTurnNumber: 0, assistantTurnNumber: 1 });
128
+
129
+ // 1つ前のgroupを採用する経路(ADR 0026)も同じ比べ方をする。
130
+ write([text('user', 'request'), text('assistant', ' answer'), text('user', 'queued request')]);
131
+ assert.deepEqual(await waitForClaudeStopTranscriptFlush({
132
+ transcriptPath, lastAssistantMessage: 'answer', timeoutMs: 100, intervalMs: 10,
133
+ isTurnCaptured: () => false, ...clock,
134
+ }), { status: 'ready', userTurnNumber: 0, assistantTurnNumber: 1 });
135
+ assert.equal(elapsed, 0);
136
+
137
+ // 空白以外が違う本文は一致させない。
138
+ write([text('user', 'request'), text('assistant', ' answer')]);
139
+ await assert.rejects(waitForClaudeStopTranscriptFlush({
140
+ transcriptPath, lastAssistantMessage: 'another answer', timeoutMs: 30, intervalMs: 10, ...clock,
141
+ }), /not visible before deadline/);
142
+ await assert.rejects(waitForClaudeStopTranscriptFlush({
143
+ transcriptPath, lastAssistantMessage: 'ans wer', timeoutMs: 30, intervalMs: 10, ...clock,
144
+ }), /not visible before deadline/);
145
+
146
+ // 空白だけのmarkerは、無いものとして扱う。
147
+ assert.deepEqual(await waitForClaudeStopTranscriptFlush({
148
+ transcriptPath, lastAssistantMessage: ' \n', timeoutMs: 30, intervalMs: 10, ...clock,
149
+ }), { status: 'marker_unavailable' });
150
+ } finally {
151
+ rmSync(root, { recursive: true, force: true });
152
+ }
153
+ });
154
+
108
155
  test('Claude Stop flush barrierは、Stop直後に次のuser行が届いても、未捕捉の完了turnを待たずに採用する', async () => {
109
156
  const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-next-user-'));
110
157
  const transcriptPath = join(root, 'transcript.jsonl');