throughline 0.3.24 → 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 +383 -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 +33 -10
  111. package/src/vscode-task.test.mjs +19 -9
@@ -0,0 +1,282 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { summarizeToL1 } from './haiku-summarizer.mjs';
7
+
8
+ function makeBin(dir, name, body) {
9
+ const path = join(dir, name);
10
+ writeFileSync(path, body);
11
+ chmodSync(path, 0o755);
12
+ return path;
13
+ }
14
+
15
+ test('summarizeToL1: returns empty fallback for blank input', () => {
16
+ const result = summarizeToL1('', {
17
+ projectPath: '/repo',
18
+ env: { ...process.env, THROUGHLINE_CODEX_SIDECAR_DISABLED: '1' },
19
+ });
20
+
21
+ assert.equal(result.summary, '(no content)');
22
+ assert.equal(result.fromFallback, true);
23
+ assert.equal(result.source, 'empty');
24
+ });
25
+
26
+ test('summarizeToL1: recursion guard returns raw L2 without spawning sidecar or haiku', () => {
27
+ const result = summarizeToL1('raw turn text', {
28
+ hostMode: 'claude-primary',
29
+ projectPath: '/repo',
30
+ env: {
31
+ ...process.env,
32
+ THROUGHLINE_IN_HAIKU_SUBPROCESS: '1',
33
+ },
34
+ });
35
+
36
+ assert.equal(result.summary, 'raw turn text');
37
+ assert.equal(result.fromFallback, true);
38
+ assert.equal(result.source, 'recursion_guard');
39
+ });
40
+
41
+ test('summarizeToL1: uses codex-sidecar when diagnostics and run both succeed', () => {
42
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-sidecar-ok-'));
43
+ try {
44
+ const sidecar = makeBin(
45
+ dir,
46
+ 'codex-sidecar',
47
+ `#!/usr/bin/env bash
48
+ if [ "$1" = "diagnostics" ]; then
49
+ printf '{"status":"ok"}\\n'
50
+ exit 0
51
+ fi
52
+ printf '{"status":"ok","summary":"sidecar summary"}\\n'
53
+ exit 0
54
+ `,
55
+ );
56
+
57
+ const result = summarizeToL1('long enough turn text', {
58
+ hostMode: 'claude-primary',
59
+ projectPath: '/repo',
60
+ env: {
61
+ ...process.env,
62
+ THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
63
+ },
64
+ });
65
+
66
+ assert.equal(result.summary, 'sidecar summary');
67
+ assert.equal(result.fromFallback, false);
68
+ assert.equal(result.source, 'codex-sidecar');
69
+ assert.equal(result.sidecarReason, 'sidecar_ok');
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ test('summarizeToL1: accepts stable SidecarResult summary without status field', () => {
76
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-sidecar-result-'));
77
+ try {
78
+ const sidecar = makeBin(
79
+ dir,
80
+ 'codex-sidecar',
81
+ `#!/usr/bin/env bash
82
+ if [ "$1" = "diagnostics" ]; then
83
+ printf '{"status":"ok"}\\n'
84
+ exit 0
85
+ fi
86
+ printf '{"summary":"stable sidecar summary","confidence":{"level":"high"},"recommendedNextAction":"continue"}\\n'
87
+ exit 0
88
+ `,
89
+ );
90
+
91
+ const result = summarizeToL1('long enough turn text', {
92
+ hostMode: 'claude-primary',
93
+ projectPath: '/repo',
94
+ env: {
95
+ ...process.env,
96
+ THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
97
+ },
98
+ });
99
+
100
+ assert.equal(result.summary, 'stable sidecar summary');
101
+ assert.equal(result.fromFallback, false);
102
+ assert.equal(result.source, 'codex-sidecar');
103
+ assert.equal(result.sidecarReason, 'sidecar_ok');
104
+ } finally {
105
+ rmSync(dir, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ test('summarizeToL1: when sidecar is disabled, keeps current Haiku-compatible path', () => {
110
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-haiku-'));
111
+ try {
112
+ makeBin(
113
+ dir,
114
+ 'claude',
115
+ `#!/usr/bin/env bash
116
+ cat >/dev/null
117
+ printf 'haiku summary\\n'
118
+ `,
119
+ );
120
+
121
+ const result = summarizeToL1('long enough turn text', {
122
+ hostMode: 'claude-primary',
123
+ projectPath: '/repo',
124
+ env: {
125
+ ...process.env,
126
+ PATH: `${dir}:${process.env.PATH ?? ''}`,
127
+ THROUGHLINE_CODEX_SIDECAR_DISABLED: '1',
128
+ },
129
+ });
130
+
131
+ assert.equal(result.summary, 'haiku summary');
132
+ assert.equal(result.fromFallback, false);
133
+ assert.equal(result.source, 'haiku');
134
+ assert.equal(result.sidecarReason, 'sidecar_disabled');
135
+ } finally {
136
+ rmSync(dir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test('summarizeToL1: sidecar run failure keeps current Haiku-compatible path', () => {
141
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-sidecar-fail-'));
142
+ try {
143
+ const sidecar = makeBin(
144
+ dir,
145
+ 'codex-sidecar',
146
+ `#!/usr/bin/env bash
147
+ if [ "$1" = "diagnostics" ]; then
148
+ printf '{"status":"ok"}\\n'
149
+ exit 0
150
+ fi
151
+ printf 'sidecar failed\\n' >&2
152
+ exit 42
153
+ `,
154
+ );
155
+ makeBin(
156
+ dir,
157
+ 'claude',
158
+ `#!/usr/bin/env bash
159
+ cat >/dev/null
160
+ printf 'haiku after sidecar failure\\n'
161
+ `,
162
+ );
163
+
164
+ const result = summarizeToL1('long enough turn text', {
165
+ hostMode: 'claude-primary',
166
+ projectPath: '/repo',
167
+ env: {
168
+ ...process.env,
169
+ PATH: `${dir}:${process.env.PATH ?? ''}`,
170
+ THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
171
+ },
172
+ });
173
+
174
+ assert.equal(result.summary, 'haiku after sidecar failure');
175
+ assert.equal(result.fromFallback, false);
176
+ assert.equal(result.source, 'haiku');
177
+ assert.equal(result.sidecarReason, 'sidecar_run_failed');
178
+ } finally {
179
+ rmSync(dir, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test('summarizeToL1: unknown host mode is an explicit error', () => {
184
+ assert.throws(
185
+ () =>
186
+ summarizeToL1('long enough turn text', {
187
+ projectPath: '/repo',
188
+ env: { ...process.env, THROUGHLINE_CODEX_SIDECAR_DISABLED: '1' },
189
+ }),
190
+ /requires hostMode/,
191
+ );
192
+ });
193
+
194
+ test('summarizeToL1: codex-primary uses Codex CLI backend', () => {
195
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-codex-'));
196
+ try {
197
+ const argsFile = join(dir, 'args.txt');
198
+ const stdinFile = join(dir, 'stdin.txt');
199
+ const codex = makeBin(
200
+ dir,
201
+ 'codex',
202
+ `#!/usr/bin/env bash
203
+ printf '%s\\n' "$@" > "${argsFile}"
204
+ cat > "${stdinFile}"
205
+ printf 'codex summary\\n'
206
+ `,
207
+ );
208
+
209
+ const result = summarizeToL1('long enough turn text', {
210
+ hostMode: 'codex-primary',
211
+ projectPath: dir,
212
+ env: {
213
+ ...process.env,
214
+ THROUGHLINE_CODEX_CLI_BIN: codex,
215
+ },
216
+ });
217
+
218
+ assert.equal(result.summary, 'codex summary');
219
+ assert.equal(result.fromFallback, false);
220
+ assert.equal(result.source, 'codex-cli');
221
+ const argsText = readFileSync(argsFile, 'utf8').trim();
222
+ const argv = argsText.split('\n');
223
+ assert.deepEqual(argv.slice(0, 8), [
224
+ 'exec',
225
+ '--ephemeral',
226
+ '--ignore-user-config',
227
+ '--ignore-rules',
228
+ '--skip-git-repo-check',
229
+ '--sandbox',
230
+ 'read-only',
231
+ '-C',
232
+ ]);
233
+ assert.equal(argv[8], dir);
234
+ assert.match(argsText, /Output contract/);
235
+ assert.equal(readFileSync(stdinFile, 'utf8'), 'long enough turn text');
236
+ } finally {
237
+ rmSync(dir, { recursive: true, force: true });
238
+ }
239
+ });
240
+
241
+ test('summarizeToL1: codex-primary failure is not hidden by fallback', () => {
242
+ const dir = mkdtempSync(join(tmpdir(), 'tl-l1-codex-fail-'));
243
+ try {
244
+ const codex = makeBin(
245
+ dir,
246
+ 'codex',
247
+ `#!/usr/bin/env bash
248
+ printf 'codex failed\\n' >&2
249
+ exit 42
250
+ `,
251
+ );
252
+ makeBin(
253
+ dir,
254
+ 'claude',
255
+ `#!/usr/bin/env bash
256
+ printf 'should not run\\n'
257
+ `,
258
+ );
259
+
260
+ assert.throws(
261
+ () =>
262
+ summarizeToL1('long enough turn text', {
263
+ hostMode: 'codex-primary',
264
+ projectPath: dir,
265
+ env: {
266
+ ...process.env,
267
+ PATH: `${dir}:${process.env.PATH ?? ''}`,
268
+ THROUGHLINE_CODEX_CLI_BIN: codex,
269
+ },
270
+ }),
271
+ (err) => {
272
+ assert.equal(err.source, 'codex-cli');
273
+ assert.equal(err.reason, 'codex_cli_failed');
274
+ assert.equal(err.exitCode, 42);
275
+ assert.match(err.stderr, /codex failed/);
276
+ return true;
277
+ },
278
+ );
279
+ } finally {
280
+ rmSync(dir, { recursive: true, force: true });
281
+ }
282
+ });
@@ -0,0 +1,108 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
+
11
+ function makeTempHome() {
12
+ return mkdtempSync(join(tmpdir(), 'tl-handoff-preview-home-'));
13
+ }
14
+
15
+ function makeTempProject() {
16
+ return mkdtempSync(join(tmpdir(), 'tl-handoff-preview-project-'));
17
+ }
18
+
19
+ async function seedDb(home, project) {
20
+ const originalHome = process.env.HOME;
21
+ const originalUserProfile = process.env.USERPROFILE;
22
+ process.env.HOME = home;
23
+ process.env.USERPROFILE = home;
24
+ try {
25
+ const mod = await import(`./db.mjs?handoffPreview=${Date.now()}-${Math.random()}`);
26
+ const db = mod.getDb();
27
+ db.prepare(
28
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
29
+ VALUES ('sess-preview', ?, 'active', 1, 2)`,
30
+ ).run(project);
31
+ db.prepare(
32
+ `INSERT INTO bodies
33
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
34
+ VALUES ('sess-preview', 'sess-preview', 1, 'assistant', 'preview body', 3, 1000)`,
35
+ ).run();
36
+ db.prepare(
37
+ `INSERT INTO details
38
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
39
+ token_count, created_at, kind, source_id)
40
+ VALUES ('sess-preview', 'sess-preview', 1, 'Bash', '{"command":"pwd"}',
41
+ NULL, 3, 1000, 'tool_input', 'toolu_preview')`,
42
+ ).run();
43
+ db.close();
44
+ } finally {
45
+ if (originalHome === undefined) delete process.env.HOME;
46
+ else process.env.HOME = originalHome;
47
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
48
+ else process.env.USERPROFILE = originalUserProfile;
49
+ }
50
+ }
51
+
52
+ function runPreview(home, project, args = []) {
53
+ return spawnSync(
54
+ process.execPath,
55
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'handoff-preview', ...args],
56
+ {
57
+ cwd: project,
58
+ env: {
59
+ ...process.env,
60
+ HOME: home,
61
+ USERPROFILE: home,
62
+ },
63
+ encoding: 'utf8',
64
+ },
65
+ );
66
+ }
67
+
68
+ test('handoff-preview prints throughline_handoff JSON for explicit session', async () => {
69
+ const home = makeTempHome();
70
+ const project = makeTempProject();
71
+ try {
72
+ await seedDb(home, project);
73
+ const result = runPreview(home, project, [
74
+ '--session',
75
+ 'sess-preview',
76
+ '--host-mode',
77
+ 'unknown',
78
+ ]);
79
+
80
+ assert.equal(result.status, 0, result.stderr);
81
+ const block = JSON.parse(result.stdout);
82
+ assert.equal(block.kind, 'throughline_handoff');
83
+ assert.equal(block.data.sessionId, 'sess-preview');
84
+ assert.equal(block.data.projectPath, project);
85
+ assert.equal(block.data.hostMode, 'unknown');
86
+ assert.equal(block.data.memory.recentBodies[0].text, 'preview body');
87
+ assert.equal(block.data.detailReferences[0].sourceId, 'toolu_preview');
88
+ } finally {
89
+ rmSync(project, { recursive: true, force: true });
90
+ rmSync(home, { recursive: true, force: true });
91
+ }
92
+ });
93
+
94
+ test('handoff-preview uses latest session for cwd when --session is omitted', async () => {
95
+ const home = makeTempHome();
96
+ const project = makeTempProject();
97
+ try {
98
+ await seedDb(home, project);
99
+ const result = runPreview(home, project);
100
+
101
+ assert.equal(result.status, 0, result.stderr);
102
+ const block = JSON.parse(result.stdout);
103
+ assert.equal(block.data.sessionId, 'sess-preview');
104
+ } finally {
105
+ rmSync(project, { recursive: true, force: true });
106
+ rmSync(home, { recursive: true, force: true });
107
+ }
108
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Agent-neutral handoff projection.
3
+ *
4
+ * This module reads the same persisted memory that Claude resume-context uses,
5
+ * but returns a stable object instead of Claude-facing Markdown. It is not
6
+ * persisted to DB; adapters can render it for Claude, Codex, or diagnostics.
7
+ */
8
+
9
+ export const HANDOFF_RECORD_VERSION = 1;
10
+ export const N_RECENT_L2 = 20;
11
+ export const CODEX_SESSION_PREFIX = 'codex:';
12
+
13
+ const DEFAULT_INTENT = 'continue implementation';
14
+ const DEFAULT_CONSTRAINTS = [
15
+ 'preserve existing Claude Code hook, slash command, transcript, baton, and resume behavior',
16
+ 'add Codex support as adapter/projection; do not rename Claude-facing DB fields or commands',
17
+ 'do not treat unverified rollback/inject host behavior as a confirmed implementation contract',
18
+ ];
19
+
20
+ /**
21
+ * Unix ms を HH:MM:SS 形式に変換する。
22
+ */
23
+ export function formatTime(unixMs) {
24
+ const d = new Date(unixMs);
25
+ const hh = String(d.getHours()).padStart(2, '0');
26
+ const mm = String(d.getMinutes()).padStart(2, '0');
27
+ const ss = String(d.getSeconds()).padStart(2, '0');
28
+ return `${hh}:${mm}:${ss}`;
29
+ }
30
+
31
+ function loadSession(db, sessionId) {
32
+ try {
33
+ return db
34
+ .prepare(
35
+ `SELECT session_id, project_path, status, created_at, updated_at, merged_into
36
+ FROM sessions
37
+ WHERE session_id = ?`,
38
+ )
39
+ .get(sessionId) ?? null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function buildBodySet(rows) {
46
+ return new Set(rows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`));
47
+ }
48
+
49
+ function distinctOriginSessionIds(...rowGroups) {
50
+ const ids = new Set();
51
+ for (const rows of rowGroups) {
52
+ for (const r of rows) {
53
+ if (r.origin_session_id) ids.add(r.origin_session_id);
54
+ }
55
+ }
56
+ return [...ids].sort();
57
+ }
58
+
59
+ function inferSourceAdapter(sessionId, originSessionIds) {
60
+ const ids = [sessionId, ...originSessionIds].filter(Boolean);
61
+ if (ids.length > 0 && ids.every((id) => String(id).startsWith(CODEX_SESSION_PREFIX))) {
62
+ return 'codex';
63
+ }
64
+ return 'claude';
65
+ }
66
+
67
+ function loadBodies(db, { sessionId, excludeOriginId, recentTurnLimit }) {
68
+ const hasExclude = Boolean(excludeOriginId);
69
+ const bodiesQuery = hasExclude
70
+ ? `SELECT origin_session_id, turn_number, role, text, created_at
71
+ FROM bodies
72
+ WHERE session_id = ? AND origin_session_id != ?
73
+ ORDER BY created_at DESC`
74
+ : `SELECT origin_session_id, turn_number, role, text, created_at
75
+ FROM bodies
76
+ WHERE session_id = ?
77
+ ORDER BY created_at DESC`;
78
+
79
+ let desc = [];
80
+ try {
81
+ desc = hasExclude
82
+ ? db.prepare(bodiesQuery).all(sessionId, excludeOriginId)
83
+ : db.prepare(bodiesQuery).all(sessionId);
84
+ } catch {
85
+ desc = [];
86
+ }
87
+
88
+ const selectedTurns = new Set();
89
+ const selectedRows = [];
90
+ for (const row of desc) {
91
+ const key = `${row.origin_session_id}\x00${row.turn_number}`;
92
+ if (!selectedTurns.has(key)) {
93
+ if (selectedTurns.size >= recentTurnLimit) continue;
94
+ selectedTurns.add(key);
95
+ }
96
+ selectedRows.push(row);
97
+ }
98
+
99
+ return selectedRows.reverse();
100
+ }
101
+
102
+ function loadL1Summaries(db, { sessionId, excludeOriginId, bodyRows }) {
103
+ const hasExclude = Boolean(excludeOriginId);
104
+ const skelQuery = hasExclude
105
+ ? `SELECT origin_session_id, turn_number, role, summary, created_at
106
+ FROM skeletons
107
+ WHERE session_id = ? AND origin_session_id != ?
108
+ ORDER BY created_at ASC`
109
+ : `SELECT origin_session_id, turn_number, role, summary, created_at
110
+ FROM skeletons
111
+ WHERE session_id = ?
112
+ ORDER BY created_at ASC`;
113
+
114
+ let all = [];
115
+ try {
116
+ all = hasExclude
117
+ ? db.prepare(skelQuery).all(sessionId, excludeOriginId)
118
+ : db.prepare(skelQuery).all(sessionId);
119
+ } catch {
120
+ all = [];
121
+ }
122
+ const bodySet = buildBodySet(bodyRows);
123
+ return all.filter((s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`));
124
+ }
125
+
126
+ function loadLatestThinking(db, { sessionId, excludeOriginId }) {
127
+ const hasExclude = Boolean(excludeOriginId);
128
+ const latestQuery = hasExclude
129
+ ? `SELECT origin_session_id, turn_number, created_at
130
+ FROM bodies
131
+ WHERE session_id = ? AND origin_session_id != ? AND role = 'assistant'
132
+ ORDER BY created_at DESC
133
+ LIMIT 1`
134
+ : `SELECT origin_session_id, turn_number, created_at
135
+ FROM bodies
136
+ WHERE session_id = ? AND role = 'assistant'
137
+ ORDER BY created_at DESC
138
+ LIMIT 1`;
139
+
140
+ let latest;
141
+ try {
142
+ latest = hasExclude
143
+ ? db.prepare(latestQuery).get(sessionId, excludeOriginId)
144
+ : db.prepare(latestQuery).get(sessionId);
145
+ } catch {
146
+ return [];
147
+ }
148
+ if (!latest) return [];
149
+
150
+ try {
151
+ return db
152
+ .prepare(
153
+ `SELECT origin_session_id, turn_number, output_text, created_at, source_id
154
+ FROM details
155
+ WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND kind = 'thinking'
156
+ ORDER BY created_at ASC`,
157
+ )
158
+ .all(sessionId, latest.origin_session_id, latest.turn_number)
159
+ .filter((r) => typeof r.output_text === 'string' && r.output_text.length > 0);
160
+ } catch {
161
+ return [];
162
+ }
163
+ }
164
+
165
+ function loadL3References(db, { sessionId, bodyRows }) {
166
+ const turnKeys = [...buildBodySet(bodyRows)];
167
+ if (turnKeys.length === 0) return [];
168
+
169
+ const tuples = turnKeys.map((k) => k.split('\x00'));
170
+ const placeholders = tuples.map(() => '(?, ?, ?)').join(', ');
171
+ const params = tuples.flatMap(([origin, turn]) => [sessionId, origin, Number(turn)]);
172
+
173
+ try {
174
+ return db
175
+ .prepare(
176
+ `SELECT kind, tool_name, source_id, origin_session_id, turn_number, created_at
177
+ FROM details
178
+ WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
179
+ ORDER BY created_at ASC, id ASC`,
180
+ )
181
+ .all(...params)
182
+ .map((r) => ({
183
+ kind: r.kind,
184
+ toolName: r.tool_name,
185
+ sourceId: r.source_id ?? null,
186
+ originSessionId: r.origin_session_id,
187
+ turnNumber: r.turn_number,
188
+ createdAt: r.created_at,
189
+ detailCommand: `throughline detail ${formatTime(r.created_at)}`,
190
+ }));
191
+ } catch {
192
+ return [];
193
+ }
194
+ }
195
+
196
+ /**
197
+ * @param {import('node:sqlite').DatabaseSync} db
198
+ * @param {{
199
+ * sessionId: string,
200
+ * isInheritance?: boolean,
201
+ * excludeOriginId?: string | null,
202
+ * inflightMemo?: string | null,
203
+ * intent?: string,
204
+ * constraints?: string[],
205
+ * recentTurnLimit?: number,
206
+ * }} params
207
+ */
208
+ export function buildHandoffRecord(
209
+ db,
210
+ {
211
+ sessionId,
212
+ isInheritance = false,
213
+ excludeOriginId = null,
214
+ inflightMemo = null,
215
+ intent = DEFAULT_INTENT,
216
+ constraints = DEFAULT_CONSTRAINTS,
217
+ recentTurnLimit = N_RECENT_L2,
218
+ },
219
+ ) {
220
+ if (!sessionId) return null;
221
+
222
+ const session = loadSession(db, sessionId);
223
+ const bodyRows = loadBodies(db, { sessionId, excludeOriginId, recentTurnLimit });
224
+ const l1Rows = loadL1Summaries(db, { sessionId, excludeOriginId, bodyRows });
225
+ const thinkingRows = loadLatestThinking(db, { sessionId, excludeOriginId });
226
+
227
+ if (
228
+ bodyRows.length === 0 &&
229
+ l1Rows.length === 0 &&
230
+ thinkingRows.length === 0 &&
231
+ !inflightMemo
232
+ ) {
233
+ return null;
234
+ }
235
+
236
+ const l3References = loadL3References(db, { sessionId, bodyRows });
237
+ const originSessionIds = distinctOriginSessionIds(bodyRows, l1Rows, thinkingRows);
238
+
239
+ return {
240
+ kind: 'handoff_record',
241
+ version: HANDOFF_RECORD_VERSION,
242
+ session: {
243
+ id: sessionId,
244
+ projectPath: session?.project_path ?? null,
245
+ status: session?.status ?? null,
246
+ mergedInto: session?.merged_into ?? null,
247
+ },
248
+ source: {
249
+ adapter: inferSourceAdapter(sessionId, originSessionIds),
250
+ inheritance: Boolean(isInheritance),
251
+ excludeOriginId: excludeOriginId ?? null,
252
+ originSessionIds,
253
+ },
254
+ intent,
255
+ constraints: [...constraints],
256
+ memory: {
257
+ inflightMemo: inflightMemo && inflightMemo.trim().length > 0 ? inflightMemo.trim() : null,
258
+ latestThinking: thinkingRows.map((r) => ({
259
+ originSessionId: r.origin_session_id,
260
+ turnNumber: r.turn_number,
261
+ text: r.output_text,
262
+ createdAt: r.created_at,
263
+ time: formatTime(r.created_at),
264
+ sourceId: r.source_id ?? null,
265
+ })),
266
+ l1Summaries: l1Rows.map((r) => ({
267
+ originSessionId: r.origin_session_id,
268
+ turnNumber: r.turn_number,
269
+ role: r.role,
270
+ summary: r.summary,
271
+ createdAt: r.created_at,
272
+ time: formatTime(r.created_at),
273
+ })),
274
+ recentBodies: bodyRows.map((r) => ({
275
+ originSessionId: r.origin_session_id,
276
+ turnNumber: r.turn_number,
277
+ role: r.role,
278
+ text: r.text,
279
+ createdAt: r.created_at,
280
+ time: formatTime(r.created_at),
281
+ })),
282
+ },
283
+ references: {
284
+ l3: l3References,
285
+ },
286
+ stats: {
287
+ l1Rows: l1Rows.length,
288
+ l2Rows: bodyRows.length,
289
+ thinkingRows: thinkingRows.length,
290
+ l3References: l3References.length,
291
+ preservedContextRows: bodyRows.length + l1Rows.length,
292
+ },
293
+ };
294
+ }