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.
Files changed (72) hide show
  1. package/CHANGELOG.md +101 -1
  2. package/README.md +86 -28
  3. package/bin/throughline.mjs +20 -0
  4. package/docs/00_overview.md +12 -0
  5. package/docs/02_clear_auto_handoff_plan.md +47 -13
  6. package/docs/04_public_release_plan.md +1 -0
  7. package/docs/14_observer_completed_turn_feed_plan.md +290 -0
  8. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
  9. package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
  10. package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
  11. package/docs/adr/0004-observer-db-pair-projection.md +71 -0
  12. package/docs/adr/0005-observer-read-pagination.md +51 -0
  13. package/docs/adr/0006-observer-page-offset-proof.md +33 -0
  14. package/docs/adr/0007-observer-read-cli-contract.md +61 -0
  15. package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
  16. package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
  17. package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
  18. package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
  19. package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
  20. package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
  21. package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
  22. package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
  23. package/docs/adr/0016-push-pull-recall-injection.md +93 -0
  24. package/package.json +1 -1
  25. package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -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 +1 -0
  34. package/src/cli/factory-diagnostics.test.mjs +6 -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/cli/recall.mjs +279 -0
  40. package/src/cli/recall.test.mjs +269 -0
  41. package/src/codex-rollout-memory.mjs +13 -0
  42. package/src/codex-rollout-memory.test.mjs +27 -0
  43. package/src/codex-thread-index.mjs +1 -1
  44. package/src/codex-thread-index.test.mjs +18 -0
  45. package/src/completed-turn-receipts.mjs +374 -0
  46. package/src/completed-turn-receipts.test.mjs +186 -0
  47. package/src/db-schema.test.mjs +9 -2
  48. package/src/db.mjs +20 -1
  49. package/src/decision-log.mjs +24 -0
  50. package/src/haiku-summarizer.mjs +93 -16
  51. package/src/haiku-summarizer.test.mjs +118 -9
  52. package/src/handoff-executor.mjs +161 -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 +337 -60
  63. package/src/resume-context.test.mjs +228 -1
  64. package/src/runtime-error-store.mjs +2 -1
  65. package/src/runtime-error-store.test.mjs +1 -1
  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 +142 -0
  72. package/src/windows-acl-test-helper.mjs +2 -2
@@ -1,14 +1,20 @@
1
1
  /**
2
2
  * haiku-summarizer.mjs — L1 要約生成
3
3
  *
4
- * 基本方針:
5
- * - Claude primary では、codex-sidecar diagnostics が configured なら Codex sidecar で
6
- * L2L1 要約する。
7
- * - Claude primary では、codex-sidecar が disabled / unavailable なら現行の Claude
8
- * Haiku 要約に戻す。
9
- * - Claude primary では、どちらも失敗したら L2 全文を L1 に入れる(情報欠損ゼロ)。
4
+ * 基本方針 (モデル・比率の根拠は ADR 0015 の実測評価):
5
+ * - Claude primary の backend 順序: codex-sidecar (configured 時)
6
+ * → Codex CLI (既定 gpt-5.6-luna / effort low) → Claude Haiku
7
+ * L2 全文を L1 に入れる(情報欠損ゼロ)。各段の失敗理由は結果に記録する。
10
8
  * - Codex primary では、Codex CLI backend を使い、失敗時は Haiku / raw L2 へ
11
9
  * fallback せず explicit error にする。
10
+ * - 要約の目標量は削減割合で決める(既定 1/5 = 0.2)。プロンプトへは割合から
11
+ * 換算した「約N文字」で渡す(LLM の長さ制御は割合指定より文字数指定が安定)。
12
+ *
13
+ * 設定 (env):
14
+ * - THROUGHLINE_L1_MODEL: Codex CLI 要約モデル (既定 'gpt-5.6-luna')
15
+ * - THROUGHLINE_L1_EFFORT: Codex CLI reasoning effort (既定 'low')
16
+ * - THROUGHLINE_L1_RATIO: 削減割合 (0 < r <= 1、既定 0.2 = 元の 1/5)。
17
+ * 不正値は黙って既定に落とさず explicit error にする。
12
18
  *
13
19
  * Claude Haiku 経路:
14
20
  * Claude Max 契約前提。`claude -p --model claude-haiku-4-5-20251001`
@@ -47,6 +53,12 @@ import {
47
53
  import { spawnPortableSync } from './portable-spawn-sync.mjs';
48
54
 
49
55
  const MODEL = 'claude-haiku-4-5-20251001';
56
+ // Codex CLI 要約の既定。gpt-5.6-luna@low@1/5 は 2026-07-17 の実測評価で選定
57
+ // (8 実ソース × effort {none,low,medium} × 2 反復、要点拾い率 low=93% で
58
+ // medium と同点・none+4pt、レイテンシ median 12.4s。ADR 0015)。
59
+ const L1_DEFAULT_CODEX_MODEL = 'gpt-5.6-luna';
60
+ const L1_DEFAULT_CODEX_EFFORT = 'low';
61
+ const L1_DEFAULT_RATIO = 0.2; // 元テキストの 1/5
50
62
  const MAX_RETRIES = 2;
51
63
  const TIMEOUT_MS = 30_000;
52
64
  const SIDECAR_TIMEOUT_MS = 10 * 60_000;
@@ -65,8 +77,33 @@ function ensureWorkdir() {
65
77
  }
66
78
  }
67
79
 
68
- function buildPrompt(l2Text) {
69
- const targetChars = Math.max(20, Math.round(l2Text.length / 5));
80
+ /**
81
+ * 削減割合を env から解決する。不正値は黙って既定へ落とさず explicit error
82
+ * (フォールバック禁止原則。設定ミスは沈黙劣化ではなく即座に見えるべき)。
83
+ */
84
+ export function resolveL1Ratio(env = process.env) {
85
+ const raw = env.THROUGHLINE_L1_RATIO;
86
+ if (raw === undefined || raw === '') return L1_DEFAULT_RATIO;
87
+ const ratio = Number(raw);
88
+ if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1) {
89
+ throw new Error(
90
+ `invalid THROUGHLINE_L1_RATIO: ${JSON.stringify(raw)} ` +
91
+ '(expected a fraction in (0, 1], e.g. 0.2 = compress to 1/5)',
92
+ );
93
+ }
94
+ return ratio;
95
+ }
96
+
97
+ function resolveL1CodexModel(env) {
98
+ return env.THROUGHLINE_L1_MODEL || L1_DEFAULT_CODEX_MODEL;
99
+ }
100
+
101
+ function resolveL1CodexEffort(env) {
102
+ return env.THROUGHLINE_L1_EFFORT || L1_DEFAULT_CODEX_EFFORT;
103
+ }
104
+
105
+ function buildPrompt(l2Text, ratio) {
106
+ const targetChars = Math.max(20, Math.round(l2Text.length * ratio));
70
107
  return (
71
108
  `次の日本語テキストを約${targetChars}文字に要約してください。` +
72
109
  `固有名詞・数値・因果関係を優先して残し、枝葉は落としてください。` +
@@ -74,9 +111,9 @@ function buildPrompt(l2Text) {
74
111
  );
75
112
  }
76
113
 
77
- function buildCodexPrompt(l2Text) {
114
+ function buildCodexPrompt(l2Text, ratio) {
78
115
  return (
79
- `${buildPrompt(l2Text)}\n\n` +
116
+ `${buildPrompt(l2Text, ratio)}\n\n` +
80
117
  'Output contract:\n' +
81
118
  '- Return only the summary text.\n' +
82
119
  '- Do not include Markdown fences, JSON, labels, or commentary.\n\n' +
@@ -228,7 +265,7 @@ function summarizeWithHaiku(l2Text, prompt, env) {
228
265
  return { summary: l2Text, fromFallback: true, source: 'raw_l2' };
229
266
  }
230
267
 
231
- function summarizeWithCodexCli(l2Text, { projectPath, env }) {
268
+ function summarizeWithCodexCli(l2Text, { projectPath, env, ratio }) {
232
269
  if (!projectPath) {
233
270
  const err = new Error('Codex CLI summarizer requires projectPath');
234
271
  err.source = 'codex-cli';
@@ -244,8 +281,11 @@ function summarizeWithCodexCli(l2Text, { projectPath, env }) {
244
281
  }
245
282
 
246
283
  const command = env.THROUGHLINE_CODEX_CLI_BIN ?? 'codex';
247
- const prompt = buildCodexPrompt(l2Text);
284
+ const prompt = buildCodexPrompt(l2Text, ratio);
248
285
  const childEnv = { ...env, [CODEX_SUMMARIZER_GUARD_ENV]: '1' };
286
+ // モデル・effort は明示指定する。--ignore-user-config は isolation のために
287
+ // 必要だが、これにより ~/.codex/config.toml のモデル選択も読まれないため、
288
+ // 明示しないと CLI 内蔵デフォルトで走ってしまう (ADR 0015 で実測確認)。
249
289
  const result = spawnPortableSync(
250
290
  command,
251
291
  [
@@ -256,6 +296,10 @@ function summarizeWithCodexCli(l2Text, { projectPath, env }) {
256
296
  '--skip-git-repo-check',
257
297
  '--sandbox',
258
298
  'read-only',
299
+ '-m',
300
+ resolveL1CodexModel(env),
301
+ '-c',
302
+ `model_reasoning_effort=${resolveL1CodexEffort(env)}`,
259
303
  '-C',
260
304
  projectPath,
261
305
  prompt,
@@ -291,10 +335,27 @@ function summarizeWithCodexCli(l2Text, { projectPath, env }) {
291
335
  }
292
336
 
293
337
  /**
294
- * L2 本文を約 1/5 に要約する。
338
+ * claude-primary 用: Codex CLI 要約を試み、失敗は throw せず理由付きで返す。
339
+ * codex-primary の explicit error 契約 (summarizeWithCodexCli) はそのまま使い、
340
+ * ここでは「次の backend へ進む」ための宣言済み fallback として理由を保存する。
341
+ */
342
+ function tryCodexCliSummary(l2Text, { projectPath, env, ratio }) {
343
+ try {
344
+ const result = summarizeWithCodexCli(l2Text, { projectPath, env, ratio });
345
+ return { summary: result.summary, reason: 'codex_cli_ok' };
346
+ } catch (err) {
347
+ return {
348
+ summary: null,
349
+ reason: err?.reason ? `codex_cli_${err.reason}` : 'codex_cli_failed',
350
+ };
351
+ }
352
+ }
353
+
354
+ /**
355
+ * L2 本文を削減割合 (既定 1/5、THROUGHLINE_L1_RATIO で変更可) で要約する。
295
356
  * @param {string} l2Text ターンの会話本文(user+assistant を適当な形式で結合した文字列)
296
357
  * @param {{ projectPath?: string, env?: NodeJS.ProcessEnv, hostMode?: 'claude-primary' | 'codex-primary' | 'unknown' }} [options]
297
- * @returns {{ summary: string, fromFallback: boolean, source?: string, sidecarReason?: string }}
358
+ * @returns {{ summary: string, fromFallback: boolean, source?: string, sidecarReason?: string, codexCliReason?: string }}
298
359
  */
299
360
  export function summarizeToL1(
300
361
  l2Text,
@@ -304,8 +365,10 @@ export function summarizeToL1(
304
365
  return { summary: '(no content)', fromFallback: true, source: 'empty' };
305
366
  }
306
367
 
368
+ const ratio = resolveL1Ratio(env);
369
+
307
370
  if (hostMode === 'codex-primary') {
308
- return summarizeWithCodexCli(l2Text, { projectPath, env });
371
+ return summarizeWithCodexCli(l2Text, { projectPath, env, ratio });
309
372
  }
310
373
 
311
374
  if (hostMode !== 'claude-primary') {
@@ -320,7 +383,7 @@ export function summarizeToL1(
320
383
  return { summary: l2Text, fromFallback: true, source: 'recursion_guard' };
321
384
  }
322
385
 
323
- const prompt = buildPrompt(l2Text);
386
+ const prompt = buildPrompt(l2Text, ratio);
324
387
  const sidecar = tryCodexSidecarSummary(l2Text, { projectPath, prompt, env });
325
388
  if (sidecar.summary) {
326
389
  return {
@@ -331,9 +394,23 @@ export function summarizeToL1(
331
394
  };
332
395
  }
333
396
 
397
+ // sidecar 不在時は Codex CLI (既定 gpt-5.6-luna@low)。Haiku より要点拾い率が
398
+ // 同等以上・レイテンシ半分以下・タイムアウト失敗なし (ADR 0015 実測)。
399
+ const codexCli = tryCodexCliSummary(l2Text, { projectPath, env, ratio });
400
+ if (codexCli.summary) {
401
+ return {
402
+ summary: codexCli.summary,
403
+ fromFallback: false,
404
+ source: 'codex-cli',
405
+ sidecarReason: sidecar.reason,
406
+ codexCliReason: codexCli.reason,
407
+ };
408
+ }
409
+
334
410
  const haiku = summarizeWithHaiku(l2Text, prompt, env);
335
411
  return {
336
412
  ...haiku,
337
413
  sidecarReason: sidecar.reason,
414
+ codexCliReason: codexCli.reason,
338
415
  };
339
416
  }
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { delimiter, join } from 'node:path';
6
- import { summarizeToL1 } from './haiku-summarizer.mjs';
6
+ import { summarizeToL1, resolveL1Ratio } from './haiku-summarizer.mjs';
7
7
 
8
8
  function makeBin(dir, name, body) {
9
9
  const script = join(dir, `${name}.mjs`);
@@ -117,7 +117,7 @@ test('summarizeToL1: accepts stable SidecarResult summary without status field',
117
117
  }
118
118
  });
119
119
 
120
- test('summarizeToL1: when sidecar is disabled, keeps current Haiku-compatible path', () => {
120
+ test('summarizeToL1: sidecar disabled + codex CLI failing falls back to Haiku path', () => {
121
121
  const dir = mkdtempSync(join(tmpdir(), 'tl-l1-haiku-'));
122
122
  try {
123
123
  makeBin(
@@ -125,6 +125,13 @@ test('summarizeToL1: when sidecar is disabled, keeps current Haiku-compatible pa
125
125
  'claude',
126
126
  `for await (const _chunk of process.stdin) {}
127
127
  process.stdout.write('haiku summary\\n');
128
+ `,
129
+ );
130
+ const codex = makeBin(
131
+ dir,
132
+ 'codex',
133
+ `process.stderr.write('codex unavailable\\n');
134
+ process.exit(9);
128
135
  `,
129
136
  );
130
137
 
@@ -134,6 +141,7 @@ process.stdout.write('haiku summary\\n');
134
141
  env: {
135
142
  ...envWithPrependedPath(dir),
136
143
  THROUGHLINE_CODEX_SIDECAR_DISABLED: '1',
144
+ THROUGHLINE_CODEX_CLI_BIN: codex,
137
145
  },
138
146
  });
139
147
 
@@ -141,12 +149,13 @@ process.stdout.write('haiku summary\\n');
141
149
  assert.equal(result.fromFallback, false);
142
150
  assert.equal(result.source, 'haiku');
143
151
  assert.equal(result.sidecarReason, 'sidecar_disabled');
152
+ assert.equal(result.codexCliReason, 'codex_cli_codex_cli_failed');
144
153
  } finally {
145
154
  rmSync(dir, { recursive: true, force: true });
146
155
  }
147
156
  });
148
157
 
149
- test('summarizeToL1: sidecar run failure keeps current Haiku-compatible path', () => {
158
+ test('summarizeToL1: sidecar run failure falls to Codex CLI (gpt-5.6-luna@low) before Haiku', () => {
150
159
  const dir = mkdtempSync(join(tmpdir(), 'tl-l1-sidecar-fail-'));
151
160
  try {
152
161
  const sidecar = makeBin(
@@ -158,34 +167,130 @@ test('summarizeToL1: sidecar run failure keeps current Haiku-compatible path', (
158
167
  process.stderr.write('sidecar failed\\n');
159
168
  process.exit(42);
160
169
  }
170
+ `,
171
+ );
172
+ const argsFile = join(dir, 'codex-args.txt');
173
+ const codex = makeBin(
174
+ dir,
175
+ 'codex',
176
+ `import { writeFileSync } from 'node:fs';
177
+ writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
178
+ for await (const _chunk of process.stdin) {}
179
+ process.stdout.write('luna summary after sidecar failure\\n');
161
180
  `,
162
181
  );
163
182
  makeBin(
164
183
  dir,
165
184
  'claude',
166
185
  `for await (const _chunk of process.stdin) {}
167
- process.stdout.write('haiku after sidecar failure\\n');
186
+ process.stdout.write('haiku should not run\\n');
168
187
  `,
169
188
  );
170
189
 
171
190
  const result = summarizeToL1('long enough turn text', {
172
191
  hostMode: 'claude-primary',
173
- projectPath: '/repo',
192
+ projectPath: dir,
174
193
  env: {
175
194
  ...envWithPrependedPath(dir),
176
195
  THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
196
+ THROUGHLINE_CODEX_CLI_BIN: codex,
177
197
  },
178
198
  });
179
199
 
180
- assert.equal(result.summary, 'haiku after sidecar failure');
200
+ assert.equal(result.summary, 'luna summary after sidecar failure');
181
201
  assert.equal(result.fromFallback, false);
182
- assert.equal(result.source, 'haiku');
202
+ assert.equal(result.source, 'codex-cli');
183
203
  assert.equal(result.sidecarReason, 'sidecar_run_failed');
204
+ assert.equal(result.codexCliReason, 'codex_cli_ok');
205
+
206
+ const argv = readFileSync(argsFile, 'utf8').trim().split('\n');
207
+ const modelIdx = argv.indexOf('-m');
208
+ assert.ok(modelIdx >= 0, 'explicit -m must be passed');
209
+ assert.equal(argv[modelIdx + 1], 'gpt-5.6-luna');
210
+ assert.ok(argv.includes('model_reasoning_effort=low'), 'explicit effort must be passed');
184
211
  } finally {
185
212
  rmSync(dir, { recursive: true, force: true });
186
213
  }
187
214
  });
188
215
 
216
+ test('summarizeToL1: THROUGHLINE_L1_MODEL / THROUGHLINE_L1_EFFORT override codex defaults', () => {
217
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-model-override-'));
218
+ try {
219
+ const argsFile = join(dir, 'codex-args.txt');
220
+ const codex = makeBin(
221
+ dir,
222
+ 'codex',
223
+ `import { writeFileSync } from 'node:fs';
224
+ writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
225
+ for await (const _chunk of process.stdin) {}
226
+ process.stdout.write('override summary\\n');
227
+ `,
228
+ );
229
+
230
+ const result = summarizeToL1('long enough turn text', {
231
+ hostMode: 'codex-primary',
232
+ projectPath: dir,
233
+ env: {
234
+ ...process.env,
235
+ THROUGHLINE_CODEX_CLI_BIN: codex,
236
+ THROUGHLINE_L1_MODEL: 'gpt-5.6-terra',
237
+ THROUGHLINE_L1_EFFORT: 'medium',
238
+ },
239
+ });
240
+
241
+ assert.equal(result.summary, 'override summary');
242
+ const argv = readFileSync(argsFile, 'utf8').trim().split('\n');
243
+ assert.equal(argv[argv.indexOf('-m') + 1], 'gpt-5.6-terra');
244
+ assert.ok(argv.includes('model_reasoning_effort=medium'));
245
+ } finally {
246
+ rmSync(dir, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ test('summarizeToL1: THROUGHLINE_L1_RATIO changes the target chars in the prompt', () => {
251
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-ratio-'));
252
+ try {
253
+ const argsFile = join(dir, 'codex-args.txt');
254
+ const codex = makeBin(
255
+ dir,
256
+ 'codex',
257
+ `import { writeFileSync } from 'node:fs';
258
+ writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
259
+ for await (const _chunk of process.stdin) {}
260
+ process.stdout.write('ratio summary\\n');
261
+ `,
262
+ );
263
+
264
+ const text = 'a'.repeat(1000);
265
+ summarizeToL1(text, {
266
+ hostMode: 'codex-primary',
267
+ projectPath: dir,
268
+ env: {
269
+ ...process.env,
270
+ THROUGHLINE_CODEX_CLI_BIN: codex,
271
+ THROUGHLINE_L1_RATIO: '0.1',
272
+ },
273
+ });
274
+
275
+ const argsText = readFileSync(argsFile, 'utf8');
276
+ assert.match(argsText, /約100文字に要約/, 'ratio 0.1 of 1000 chars = 100 target chars');
277
+ } finally {
278
+ rmSync(dir, { recursive: true, force: true });
279
+ }
280
+ });
281
+
282
+ test('resolveL1Ratio: default 0.2, and invalid values are explicit errors (no silent default)', () => {
283
+ assert.equal(resolveL1Ratio({}), 0.2);
284
+ assert.equal(resolveL1Ratio({ THROUGHLINE_L1_RATIO: '0.1' }), 0.1);
285
+ for (const bad of ['abc', '0', '-0.2', '1.5', 'NaN']) {
286
+ assert.throws(
287
+ () => resolveL1Ratio({ THROUGHLINE_L1_RATIO: bad }),
288
+ /invalid THROUGHLINE_L1_RATIO/,
289
+ `must reject ${bad}`,
290
+ );
291
+ }
292
+ });
293
+
189
294
  test('summarizeToL1: unknown host mode is an explicit error', () => {
190
295
  assert.throws(
191
296
  () =>
@@ -228,7 +333,7 @@ process.stdout.write('codex summary\\n');
228
333
  assert.equal(result.source, 'codex-cli');
229
334
  const argsText = readFileSync(argsFile, 'utf8').trim();
230
335
  const argv = argsText.split('\n');
231
- assert.deepEqual(argv.slice(0, 8), [
336
+ assert.deepEqual(argv.slice(0, 12), [
232
337
  'exec',
233
338
  '--ephemeral',
234
339
  '--ignore-user-config',
@@ -236,9 +341,13 @@ process.stdout.write('codex summary\\n');
236
341
  '--skip-git-repo-check',
237
342
  '--sandbox',
238
343
  'read-only',
344
+ '-m',
345
+ 'gpt-5.6-luna',
346
+ '-c',
347
+ 'model_reasoning_effort=low',
239
348
  '-C',
240
349
  ]);
241
- assert.equal(argv[8], dir);
350
+ assert.equal(argv[12], dir);
242
351
  assert.match(argsText, /Output contract/);
243
352
  assert.equal(readFileSync(stdinFile, 'utf8'), 'long enough turn text');
244
353
  } finally {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * handoff-executor.mjs — 最初の UserPromptSubmit で実行する引き継ぎ本体
3
+ *
4
+ * 二相ハンドオフ (ADR 0014) の第二相。SessionStart が登録した pending intent を
5
+ * 消費し、baton path 優先 → auto path の順で前任を merge して、予算内の
6
+ * resume context を組み立てて返す。呼び出し元 (prompt-submit) が stdout へ書く。
7
+ *
8
+ * 幽霊セッション (transcript を生成しない SessionStart) はプロンプトを発火しない
9
+ * ため、この経路に到達できない。= バトンも auto 前任も実体のあるセッションだけが
10
+ * 受け取れる。
11
+ */
12
+
13
+ import { existsSync } from 'node:fs';
14
+ import { consumeBaton } from './baton.mjs';
15
+ import { consumePendingHandoff } from './pending-handoff.mjs';
16
+ import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
17
+ import { backfillBodies, deriveTranscriptPath, logBackfill } from './turn-backfill.mjs';
18
+ import { buildBudgetedResumeContext } from './resume-context.mjs';
19
+ import { readAllSessionStates } from './state-file.mjs';
20
+
21
+ /**
22
+ * @param {import('node:sqlite').DatabaseSync} db
23
+ * @param {{ sessionId: string, projectPath: string, now?: number }} params
24
+ * @returns {{
25
+ * attempted: boolean, // pending 行が存在した (= newborn の初回プロンプト)
26
+ * triggeredPath: 'baton' | 'auto' | null,
27
+ * baton: object | null, // consumeBaton の生返り値 (ログ用)
28
+ * pendingCreatedAt: number | null,
29
+ * mergeResult: object, // mergeSpecificPredecessor の返り値 or skip
30
+ * injectionText: string | null, // 予算内注入テキスト (merge 成立時のみ)
31
+ * injectionStats: object | null, // dropped counts 等 (ログ用)
32
+ * }}
33
+ */
34
+ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Date.now() }) {
35
+ const pending = consumePendingHandoff(db, { sessionId });
36
+ if (!pending) {
37
+ return {
38
+ attempted: false,
39
+ triggeredPath: null,
40
+ baton: null,
41
+ pendingCreatedAt: null,
42
+ mergeResult: { merged: false, skipReason: 'not_newborn' },
43
+ injectionText: null,
44
+ injectionStats: null,
45
+ };
46
+ }
47
+
48
+ // baton 適格性はセッション誕生時刻 (pending.createdAt) 基準。
49
+ // 誕生後に書かれたバトンは future_baton として残置される (本来の後継のもの)。
50
+ const baton = consumeBaton(db, { projectPath, now, bornAt: pending.createdAt });
51
+
52
+ let triggeredPath = null;
53
+ let mergeResult = { merged: false, skipReason: 'no_trigger' };
54
+
55
+ if (baton.sessionId) {
56
+ triggeredPath = 'baton';
57
+ const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
58
+ mergeResult = mergeSpecificPredecessor(db, {
59
+ newSessionId: sessionId,
60
+ predecessorId,
61
+ now,
62
+ });
63
+ } else if (pending.autoPredecessorId) {
64
+ triggeredPath = 'auto';
65
+ const { target: predecessorId } = resolveMergeTarget(db, pending.autoPredecessorId);
66
+ mergeResult = mergeSpecificPredecessor(db, {
67
+ newSessionId: sessionId,
68
+ predecessorId,
69
+ now,
70
+ });
71
+ }
72
+
73
+ let injectionText = null;
74
+ let injectionStats = null;
75
+
76
+ if (mergeResult.merged) {
77
+ const predecessorId = mergeResult.predecessorId;
78
+ // /clear 直前ターンの取りこぼしを注入前に回収する。前任の transcript path は
79
+ // project path から決定的に導出する — state ファイルは Stop 不発の前任
80
+ // (まさに回収したい事例) では存在しないため補助。
81
+ const derivedTranscriptPath = deriveTranscriptPath(projectPath, predecessorId);
82
+ const stateTranscriptPath = readAllSessionStates().find(
83
+ (state) => state.sessionId === predecessorId,
84
+ )?.transcriptPath;
85
+ const predecessorTranscriptPath = existsSync(derivedTranscriptPath)
86
+ ? derivedTranscriptPath
87
+ : stateTranscriptPath && existsSync(stateTranscriptPath)
88
+ ? stateTranscriptPath
89
+ : null;
90
+
91
+ if (predecessorTranscriptPath) {
92
+ try {
93
+ const backfill = backfillBodies(db, {
94
+ targetSessionId: sessionId,
95
+ originSessionId: predecessorId,
96
+ transcriptPath: predecessorTranscriptPath,
97
+ now,
98
+ });
99
+ logBackfill({
100
+ ts: new Date(now).toISOString(),
101
+ hook: 'prompt-submit',
102
+ session_id: sessionId,
103
+ target: sessionId,
104
+ origin: predecessorId,
105
+ transcript_path: predecessorTranscriptPath,
106
+ groups: backfill.groups,
107
+ inserted_turns: backfill.insertedTurns,
108
+ skipped_existing: backfill.skippedExisting,
109
+ });
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ process.stderr.write(`[prompt-submit:backfill] ${message}\n`);
113
+ logBackfill({
114
+ ts: new Date(now).toISOString(),
115
+ hook: 'prompt-submit',
116
+ session_id: sessionId,
117
+ target: sessionId,
118
+ origin: predecessorId,
119
+ transcript_path: predecessorTranscriptPath,
120
+ error: message,
121
+ });
122
+ }
123
+ } else {
124
+ logBackfill({
125
+ ts: new Date(now).toISOString(),
126
+ hook: 'prompt-submit',
127
+ session_id: sessionId,
128
+ target: sessionId,
129
+ origin: predecessorId,
130
+ transcript_path: null,
131
+ skip_reason: 'no_transcript_path',
132
+ });
133
+ }
134
+
135
+ const budgeted = buildBudgetedResumeContext(db, {
136
+ sessionId,
137
+ isInheritance: true,
138
+ });
139
+ if (budgeted) {
140
+ injectionText = budgeted.text;
141
+ injectionStats = {
142
+ total_chars: budgeted.totalChars,
143
+ injected_l2_turns: budgeted.injectedL2Turns,
144
+ remaining_l2_turns: budgeted.remainingL2Turns,
145
+ older_turns: budgeted.olderTurns,
146
+ older_summarized: budgeted.olderSummarized,
147
+ truncated_newest_l2: budgeted.truncatedNewestL2,
148
+ };
149
+ }
150
+ }
151
+
152
+ return {
153
+ attempted: true,
154
+ triggeredPath,
155
+ baton,
156
+ pendingCreatedAt: pending.createdAt,
157
+ mergeResult,
158
+ injectionText,
159
+ injectionStats,
160
+ };
161
+ }