throughline 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -4
- package/README.md +77 -25
- package/bin/throughline.mjs +14 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +39 -13
- package/docs/04_public_release_plan.md +2 -1
- package/docs/13_native_factory_diagnostics_plan.md +4 -2
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +5 -2
- package/src/cli/factory-diagnostics.test.mjs +31 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +373 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/factory-diagnostics.mjs +0 -1
- package/src/factory-diagnostics.test.mjs +18 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +159 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +226 -62
- package/src/resume-context.test.mjs +134 -1
- package/src/runtime-error-store.mjs +74 -32
- package/src/runtime-error-store.test.mjs +51 -3
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +141 -0
- package/src/windows-acl-test-helper.mjs +29 -0
package/src/haiku-summarizer.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* haiku-summarizer.mjs — L1 要約生成
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - Claude primary
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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, '
|
|
200
|
+
assert.equal(result.summary, 'luna summary after sidecar failure');
|
|
181
201
|
assert.equal(result.fromFallback, false);
|
|
182
|
-
assert.equal(result.source, '
|
|
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,
|
|
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[
|
|
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,159 @@
|
|
|
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
|
+
dropped_l1_rows: budgeted.droppedL1Rows,
|
|
144
|
+
dropped_l2_rows: budgeted.droppedL2Rows,
|
|
145
|
+
truncated_newest_l2: budgeted.truncatedNewestL2,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
attempted: true,
|
|
152
|
+
triggeredPath,
|
|
153
|
+
baton,
|
|
154
|
+
pendingCreatedAt: pending.createdAt,
|
|
155
|
+
mergeResult,
|
|
156
|
+
injectionText,
|
|
157
|
+
injectionStats,
|
|
158
|
+
};
|
|
159
|
+
}
|