throughline 0.3.23 → 0.3.25

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 (111) hide show
  1. package/.claude/commands/tl-trim.md +42 -0
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +583 -0
  4. package/README.ja.md +42 -5
  5. package/README.md +400 -23
  6. package/bin/throughline.mjs +168 -4
  7. package/codex/skills/throughline/SKILL.md +157 -0
  8. package/codex/skills/throughline/agents/openai.yaml +7 -0
  9. package/docs/INHERITANCE_ON_CLEAR_ONLY.md +146 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +184 -0
  12. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  13. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  14. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  15. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  17. package/docs/archive/CONCEPT.md +476 -0
  18. package/docs/archive/EXPERIMENT.md +371 -0
  19. package/docs/archive/README.md +22 -0
  20. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  21. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  22. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  23. package/docs/throughline-handoff-context.example.json +57 -0
  24. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  25. package/package.json +6 -2
  26. package/src/cli/codex-capture.mjs +95 -0
  27. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  28. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  29. package/src/cli/codex-handoff-smoke.mjs +163 -0
  30. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  31. package/src/cli/codex-handoff-start.mjs +291 -0
  32. package/src/cli/codex-handoff-start.test.mjs +194 -0
  33. package/src/cli/codex-hook.mjs +276 -0
  34. package/src/cli/codex-hook.test.mjs +293 -0
  35. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  36. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  37. package/src/cli/codex-restore-smoke.mjs +357 -0
  38. package/src/cli/codex-restore-source-audit.mjs +304 -0
  39. package/src/cli/codex-resume.mjs +138 -0
  40. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  41. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  42. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  43. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  44. package/src/cli/codex-summarize.mjs +224 -0
  45. package/src/cli/codex-threads.mjs +89 -0
  46. package/src/cli/codex-visibility-smoke.mjs +196 -0
  47. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  48. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  49. package/src/cli/doctor.mjs +503 -1
  50. package/src/cli/doctor.test.mjs +542 -3
  51. package/src/cli/handoff-preview.mjs +78 -0
  52. package/src/cli/help.test.mjs +64 -0
  53. package/src/cli/install.mjs +227 -4
  54. package/src/cli/install.test.mjs +207 -4
  55. package/src/cli/trim.mjs +564 -0
  56. package/src/codex-app-server.mjs +1816 -0
  57. package/src/codex-app-server.test.mjs +512 -0
  58. package/src/codex-auto-refresh.mjs +194 -0
  59. package/src/codex-auto-refresh.test.mjs +182 -0
  60. package/src/codex-capture.mjs +235 -0
  61. package/src/codex-capture.test.mjs +393 -0
  62. package/src/codex-handoff-model-smoke.mjs +114 -0
  63. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  64. package/src/codex-handoff-smoke.mjs +124 -0
  65. package/src/codex-handoff-smoke.test.mjs +103 -0
  66. package/src/codex-handoff.mjs +331 -0
  67. package/src/codex-handoff.test.mjs +220 -0
  68. package/src/codex-host-primitive-audit.mjs +374 -0
  69. package/src/codex-host-primitive-audit.test.mjs +208 -0
  70. package/src/codex-restore-smoke.test.mjs +639 -0
  71. package/src/codex-restore-source-audit.mjs +1348 -0
  72. package/src/codex-restore-source-audit.test.mjs +623 -0
  73. package/src/codex-resume.test.mjs +242 -0
  74. package/src/codex-rollout-memory.mjs +711 -0
  75. package/src/codex-rollout-memory.test.mjs +610 -0
  76. package/src/codex-sidecar-cli.test.mjs +75 -0
  77. package/src/codex-sidecar.mjs +246 -0
  78. package/src/codex-sidecar.test.mjs +172 -0
  79. package/src/codex-summarize.test.mjs +143 -0
  80. package/src/codex-thread-identity.mjs +23 -0
  81. package/src/codex-thread-index.mjs +173 -0
  82. package/src/codex-thread-index.test.mjs +164 -0
  83. package/src/codex-usage.mjs +110 -0
  84. package/src/codex-usage.test.mjs +140 -0
  85. package/src/codex-visibility-smoke.test.mjs +222 -0
  86. package/src/codex-vscode-restore-smoke.mjs +206 -0
  87. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  88. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  89. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  90. package/src/db-schema.test.mjs +97 -0
  91. package/src/haiku-summarizer.mjs +267 -26
  92. package/src/haiku-summarizer.test.mjs +282 -0
  93. package/src/handoff-preview.test.mjs +108 -0
  94. package/src/handoff-record.mjs +294 -0
  95. package/src/handoff-record.test.mjs +226 -0
  96. package/src/hook-entrypoints.test.mjs +326 -0
  97. package/src/package-files.test.mjs +19 -0
  98. package/src/prompt-submit.mjs +9 -6
  99. package/src/resume-context.mjs +44 -140
  100. package/src/resume-context.test.mjs +172 -0
  101. package/src/session-start.mjs +8 -5
  102. package/src/state-file.mjs +50 -6
  103. package/src/state-file.test.mjs +50 -0
  104. package/src/token-monitor.mjs +14 -10
  105. package/src/token-monitor.test.mjs +27 -0
  106. package/src/trim-cli.test.mjs +1584 -0
  107. package/src/trim-model.mjs +584 -0
  108. package/src/trim-model.test.mjs +568 -0
  109. package/src/turn-processor.mjs +17 -10
  110. package/src/vscode-task.mjs +94 -6
  111. package/src/vscode-task.test.mjs +186 -6
@@ -0,0 +1,97 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ async function withIsolatedDb(testFn) {
8
+ const home = mkdtempSync(join(tmpdir(), 'tl-db-schema-'));
9
+ const originalHome = process.env.HOME;
10
+ const originalUserProfile = process.env.USERPROFILE;
11
+ process.env.HOME = home;
12
+ process.env.USERPROFILE = home;
13
+ try {
14
+ const mod = await import(`./db.mjs?isolated=${Date.now()}-${Math.random()}`);
15
+ const db = mod.getDb();
16
+ try {
17
+ await testFn(db);
18
+ } finally {
19
+ db.close();
20
+ }
21
+ } finally {
22
+ if (originalHome === undefined) delete process.env.HOME;
23
+ else process.env.HOME = originalHome;
24
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
25
+ else process.env.USERPROFILE = originalUserProfile;
26
+ rmSync(home, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ function columnNames(db, table) {
31
+ return db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name);
32
+ }
33
+
34
+ function indexNames(db) {
35
+ return db
36
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")
37
+ .all()
38
+ .map((row) => row.name);
39
+ }
40
+
41
+ test('schema v7 preserves Claude-facing tables, fields, and unique indexes', async () => {
42
+ await withIsolatedDb((db) => {
43
+ const version = db.prepare('PRAGMA user_version').get();
44
+ assert.equal(version.user_version, 7);
45
+
46
+ assert.deepEqual(columnNames(db, 'sessions'), [
47
+ 'session_id',
48
+ 'project_path',
49
+ 'status',
50
+ 'created_at',
51
+ 'updated_at',
52
+ 'merged_into',
53
+ ]);
54
+ assert.deepEqual(columnNames(db, 'skeletons'), [
55
+ 'id',
56
+ 'session_id',
57
+ 'turn_number',
58
+ 'role',
59
+ 'summary',
60
+ 'created_at',
61
+ 'origin_session_id',
62
+ ]);
63
+ assert.deepEqual(columnNames(db, 'bodies'), [
64
+ 'id',
65
+ 'session_id',
66
+ 'origin_session_id',
67
+ 'turn_number',
68
+ 'role',
69
+ 'text',
70
+ 'token_count',
71
+ 'created_at',
72
+ ]);
73
+ assert.deepEqual(columnNames(db, 'details'), [
74
+ 'id',
75
+ 'session_id',
76
+ 'turn_number',
77
+ 'tool_name',
78
+ 'input_text',
79
+ 'output_text',
80
+ 'token_count',
81
+ 'created_at',
82
+ 'origin_session_id',
83
+ 'kind',
84
+ 'source_id',
85
+ ]);
86
+ assert.deepEqual(columnNames(db, 'handoff_batons'), [
87
+ 'project_path',
88
+ 'session_id',
89
+ 'created_at',
90
+ 'memo_text',
91
+ ]);
92
+
93
+ const indexes = indexNames(db);
94
+ assert.ok(indexes.includes('uq_skeletons_turn_v3'));
95
+ assert.ok(indexes.includes('uq_details_source'));
96
+ });
97
+ });
@@ -1,11 +1,21 @@
1
1
  /**
2
- * haiku-summarizer.mjs — Claude Haiku 4.5 を使った同期 L1 要約生成
2
+ * haiku-summarizer.mjs — L1 要約生成
3
3
  *
4
- * 呼び出し経路: Claude Max 契約前提。`claude -p --model claude-haiku-4-5-20251001`
5
- * を子プロセス起動する。Anthropic API キーは使わない(Claude Code CLI
6
- * Max 契約の認証を持っている前提)。
4
+ * 基本方針:
5
+ * - Claude primary では、codex-sidecar diagnostics configured なら Codex sidecar で
6
+ * L2→L1 要約する。
7
+ * - Claude primary では、codex-sidecar が disabled / unavailable なら現行の Claude
8
+ * Haiku 要約に戻す。
9
+ * - Claude primary では、どちらも失敗したら L2 全文を L1 に入れる(情報欠損ゼロ)。
10
+ * - Codex primary では、Codex CLI backend を使い、失敗時は Haiku / raw L2 へ
11
+ * fallback せず explicit error にする。
7
12
  *
8
- * 【再帰暴走の根本対策: 隔離 cwd で spawn】
13
+ * Claude Haiku 経路:
14
+ * Claude Max 契約前提。`claude -p --model claude-haiku-4-5-20251001`
15
+ * を子プロセス起動する。Anthropic API キーは使わない(Claude Code CLI が
16
+ * Max 契約の認証を持っている前提)。
17
+ *
18
+ * 【Haiku 再帰暴走の根本対策: 隔離 cwd で spawn】
9
19
  * 素朴に `claude -p` を spawn すると subprocess が同じ .claude/settings.json を
10
20
  * 読んで Throughline の Stop hook を起動し、無限再帰になる。
11
21
  *
@@ -27,14 +37,22 @@
27
37
  */
28
38
 
29
39
  import { spawnSync } from 'child_process';
30
- import { mkdirSync } from 'fs';
40
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
31
41
  import { join } from 'path';
32
- import { homedir } from 'os';
42
+ import { homedir, tmpdir } from 'os';
43
+ import {
44
+ diagnoseCodexSidecar,
45
+ CODEX_SIDECAR_STATUS,
46
+ runCodexSidecarCommand,
47
+ } from './codex-sidecar.mjs';
33
48
 
34
49
  const MODEL = 'claude-haiku-4-5-20251001';
35
50
  const MAX_RETRIES = 2;
36
51
  const TIMEOUT_MS = 30_000;
52
+ const SIDECAR_TIMEOUT_MS = 10 * 60_000;
53
+ const CODEX_CLI_TIMEOUT_MS = 60_000;
37
54
  const RECURSION_GUARD_ENV = 'THROUGHLINE_IN_HAIKU_SUBPROCESS';
55
+ const CODEX_SUMMARIZER_GUARD_ENV = 'THROUGHLINE_IN_CODEX_SUMMARIZER';
38
56
 
39
57
  // 隔離 cwd: Throughline project-local 設定が見つからない空ディレクトリ
40
58
  const HAIKU_WORKDIR = join(homedir(), '.throughline', 'haiku-workdir');
@@ -47,29 +65,141 @@ function ensureWorkdir() {
47
65
  }
48
66
  }
49
67
 
50
- /**
51
- * L2 本文を約 1/5 に要約する。
52
- * @param {string} l2Text ターンの会話本文(user+assistant を適当な形式で結合した文字列)
53
- * @returns {{ summary: string, fromFallback: boolean }}
54
- */
55
- export function summarizeToL1(l2Text) {
56
- if (!l2Text || !l2Text.trim()) {
57
- return { summary: '(no content)', fromFallback: true };
68
+ function buildPrompt(l2Text) {
69
+ const targetChars = Math.max(20, Math.round(l2Text.length / 5));
70
+ return (
71
+ `次の日本語テキストを約${targetChars}文字に要約してください。` +
72
+ `固有名詞・数値・因果関係を優先して残し、枝葉は落としてください。` +
73
+ `要約文だけを出力し、前置きや説明は不要です。`
74
+ );
75
+ }
76
+
77
+ function buildCodexPrompt(l2Text) {
78
+ return (
79
+ `${buildPrompt(l2Text)}\n\n` +
80
+ 'Output contract:\n' +
81
+ '- Return only the summary text.\n' +
82
+ '- Do not include Markdown fences, JSON, labels, or commentary.\n\n' +
83
+ 'Text to summarize is provided on stdin.'
84
+ );
85
+ }
86
+
87
+ function compactSubprocessStderr(stderr) {
88
+ if (!stderr) return '';
89
+ const compacted = String(stderr)
90
+ .split('\n')
91
+ .map((line) => (line.length > 600 ? `${line.slice(0, 600)} ...[line truncated]` : line))
92
+ .join('\n');
93
+ if (compacted.length <= 6_000) return compacted;
94
+ return `${compacted.slice(0, 1_500)}\n...[stderr truncated]...\n${compacted.slice(-3_500)}`;
95
+ }
96
+
97
+ function parseSidecarSummary(stdout) {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(stdout);
101
+ } catch {
102
+ return null;
103
+ }
104
+ if (parsed?.status && !['ok', 'completed'].includes(parsed.status)) return null;
105
+ if (typeof parsed.summary === 'string' && parsed.summary.trim()) {
106
+ return parsed.summary.trim();
58
107
  }
108
+ if (typeof parsed.recommendation === 'string' && parsed.recommendation.trim()) {
109
+ return parsed.recommendation.trim();
110
+ }
111
+ return null;
112
+ }
59
113
 
60
- // 防御(念のため): 自分自身が Haiku subprocess 内で呼ばれていたら再帰せず即フォールバック
61
- if (process.env[RECURSION_GUARD_ENV] === '1') {
62
- return { summary: l2Text, fromFallback: true };
114
+ function tryCodexSidecarSummary(l2Text, { projectPath, prompt, env }) {
115
+ if (!projectPath) {
116
+ return { summary: null, reason: 'missing_project_path' };
63
117
  }
64
118
 
65
- const targetChars = Math.max(20, Math.round(l2Text.length / 5));
66
- const prompt =
67
- `次の日本語テキストを約${targetChars}文字に要約してください。` +
68
- `固有名詞・数値・因果関係を優先して残し、枝葉は落としてください。` +
69
- `要約文だけを出力し、前置きや説明は不要です。`;
119
+ const diagnostics = diagnoseCodexSidecar({
120
+ projectPath,
121
+ preset: 'summarize-l1',
122
+ env,
123
+ timeoutMs: SIDECAR_TIMEOUT_MS,
124
+ });
125
+ if (diagnostics.status !== CODEX_SIDECAR_STATUS.CONFIGURED) {
126
+ return {
127
+ summary: null,
128
+ reason: `sidecar_${diagnostics.status}`,
129
+ diagnostics,
130
+ };
131
+ }
132
+
133
+ const contextDir = mkdtempSync(join(tmpdir(), 'throughline-l1-context-'));
134
+ const contextFile = join(contextDir, 'context.json');
135
+ try {
136
+ writeFileSync(
137
+ contextFile,
138
+ JSON.stringify(
139
+ [
140
+ {
141
+ kind: 'manual_note',
142
+ source: 'throughline:l2-turn',
143
+ trust: 'local',
144
+ summary: 'Throughline L2 turn text to summarize into L1 memory.',
145
+ data: {
146
+ text: l2Text,
147
+ },
148
+ },
149
+ ],
150
+ null,
151
+ 2,
152
+ ),
153
+ );
154
+
155
+ const command = env.THROUGHLINE_CODEX_SIDECAR_BIN ?? 'codex-sidecar';
156
+ const result = runCodexSidecarCommand(
157
+ command,
158
+ [
159
+ 'explore',
160
+ '--project',
161
+ projectPath,
162
+ '--preset',
163
+ 'summarize-l1',
164
+ '--context-file',
165
+ contextFile,
166
+ '--turn-timeout-ms',
167
+ String(SIDECAR_TIMEOUT_MS),
168
+ prompt,
169
+ ],
170
+ {
171
+ encoding: 'utf8',
172
+ env,
173
+ timeout: SIDECAR_TIMEOUT_MS + 5_000,
174
+ },
175
+ );
176
+
177
+ if (result.status !== 0 || !result.stdout) {
178
+ return {
179
+ summary: null,
180
+ reason: 'sidecar_run_failed',
181
+ exitCode: result.status,
182
+ stderr: result.stderr ?? '',
183
+ };
184
+ }
185
+
186
+ const summary = parseSidecarSummary(result.stdout);
187
+ if (!summary) {
188
+ return {
189
+ summary: null,
190
+ reason: 'sidecar_summary_missing',
191
+ stdout: result.stdout,
192
+ };
193
+ }
194
+ return { summary, reason: 'sidecar_ok' };
195
+ } finally {
196
+ rmSync(contextDir, { recursive: true, force: true });
197
+ }
198
+ }
70
199
 
200
+ function summarizeWithHaiku(l2Text, prompt, env) {
71
201
  // child_process に渡す env: 親の env を継承しつつ再帰ガードをセット
72
- const childEnv = { ...process.env, [RECURSION_GUARD_ENV]: '1' };
202
+ const childEnv = { ...env, [RECURSION_GUARD_ENV]: '1' };
73
203
 
74
204
  // 隔離 cwd を準備(project-local .claude/settings.json が見えない場所)
75
205
  ensureWorkdir();
@@ -87,7 +217,7 @@ export function summarizeToL1(l2Text) {
87
217
 
88
218
  if (result.status === 0 && result.stdout) {
89
219
  const summary = result.stdout.trim();
90
- if (summary) return { summary, fromFallback: false };
220
+ if (summary) return { summary, fromFallback: false, source: 'haiku' };
91
221
  }
92
222
  // status != 0 や空出力は失敗とみなしてリトライ
93
223
  } catch {
@@ -96,5 +226,116 @@ export function summarizeToL1(l2Text) {
96
226
  }
97
227
 
98
228
  // 全リトライ失敗 → L2 全文をそのまま L1 に(情報欠損ゼロ)
99
- return { summary: l2Text, fromFallback: true };
229
+ return { summary: l2Text, fromFallback: true, source: 'raw_l2' };
230
+ }
231
+
232
+ function summarizeWithCodexCli(l2Text, { projectPath, env }) {
233
+ if (!projectPath) {
234
+ const err = new Error('Codex CLI summarizer requires projectPath');
235
+ err.source = 'codex-cli';
236
+ err.reason = 'missing_project_path';
237
+ throw err;
238
+ }
239
+
240
+ if (env[CODEX_SUMMARIZER_GUARD_ENV] === '1') {
241
+ const err = new Error('Codex CLI summarizer recursion guard');
242
+ err.source = 'codex-cli';
243
+ err.reason = 'recursion_guard';
244
+ throw err;
245
+ }
246
+
247
+ const command = env.THROUGHLINE_CODEX_CLI_BIN ?? 'codex';
248
+ const prompt = buildCodexPrompt(l2Text);
249
+ const childEnv = { ...env, [CODEX_SUMMARIZER_GUARD_ENV]: '1' };
250
+ const result = spawnSync(
251
+ command,
252
+ [
253
+ 'exec',
254
+ '--ephemeral',
255
+ '--ignore-user-config',
256
+ '--ignore-rules',
257
+ '--skip-git-repo-check',
258
+ '--sandbox',
259
+ 'read-only',
260
+ '-C',
261
+ projectPath,
262
+ prompt,
263
+ ],
264
+ {
265
+ input: l2Text,
266
+ encoding: 'utf8',
267
+ timeout: CODEX_CLI_TIMEOUT_MS,
268
+ shell: process.platform === 'win32',
269
+ env: childEnv,
270
+ cwd: projectPath,
271
+ },
272
+ );
273
+
274
+ if (result.status !== 0) {
275
+ const err = new Error(`Codex CLI summarizer failed: exit ${result.status ?? 'unknown'}`);
276
+ err.source = 'codex-cli';
277
+ err.reason = 'codex_cli_failed';
278
+ err.exitCode = result.status;
279
+ err.stderr = compactSubprocessStderr(result.stderr);
280
+ throw err;
281
+ }
282
+
283
+ const summary = result.stdout?.trim();
284
+ if (!summary) {
285
+ const err = new Error('Codex CLI summarizer returned empty output');
286
+ err.source = 'codex-cli';
287
+ err.reason = 'empty_output';
288
+ err.stderr = compactSubprocessStderr(result.stderr);
289
+ throw err;
290
+ }
291
+
292
+ return { summary, fromFallback: false, source: 'codex-cli' };
293
+ }
294
+
295
+ /**
296
+ * L2 本文を約 1/5 に要約する。
297
+ * @param {string} l2Text ターンの会話本文(user+assistant を適当な形式で結合した文字列)
298
+ * @param {{ projectPath?: string, env?: NodeJS.ProcessEnv, hostMode?: 'claude-primary' | 'codex-primary' | 'unknown' }} [options]
299
+ * @returns {{ summary: string, fromFallback: boolean, source?: string, sidecarReason?: string }}
300
+ */
301
+ export function summarizeToL1(
302
+ l2Text,
303
+ { projectPath = null, env = process.env, hostMode = 'unknown' } = {},
304
+ ) {
305
+ if (!l2Text || !l2Text.trim()) {
306
+ return { summary: '(no content)', fromFallback: true, source: 'empty' };
307
+ }
308
+
309
+ if (hostMode === 'codex-primary') {
310
+ return summarizeWithCodexCli(l2Text, { projectPath, env });
311
+ }
312
+
313
+ if (hostMode !== 'claude-primary') {
314
+ const err = new Error('summarizeToL1 requires hostMode claude-primary or codex-primary');
315
+ err.source = 'unknown';
316
+ err.reason = 'unknown_host_mode';
317
+ throw err;
318
+ }
319
+
320
+ // 防御(念のため): 自分自身が Haiku subprocess 内で呼ばれていたら再帰せず即フォールバック
321
+ if (env[RECURSION_GUARD_ENV] === '1') {
322
+ return { summary: l2Text, fromFallback: true, source: 'recursion_guard' };
323
+ }
324
+
325
+ const prompt = buildPrompt(l2Text);
326
+ const sidecar = tryCodexSidecarSummary(l2Text, { projectPath, prompt, env });
327
+ if (sidecar.summary) {
328
+ return {
329
+ summary: sidecar.summary,
330
+ fromFallback: false,
331
+ source: 'codex-sidecar',
332
+ sidecarReason: sidecar.reason,
333
+ };
334
+ }
335
+
336
+ const haiku = summarizeWithHaiku(l2Text, prompt, env);
337
+ return {
338
+ ...haiku,
339
+ sidecarReason: sidecar.reason,
340
+ };
100
341
  }