throughline 0.3.24 → 0.4.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 (116) hide show
  1. package/.claude/commands/tl.md +6 -21
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +632 -0
  4. package/README.ja.md +71 -46
  5. package/README.md +420 -76
  6. package/bin/throughline.mjs +169 -7
  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 +159 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
  12. package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
  13. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  14. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  15. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  17. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  18. package/docs/archive/CONCEPT.md +476 -0
  19. package/docs/archive/EXPERIMENT.md +371 -0
  20. package/docs/archive/README.md +22 -0
  21. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  22. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  23. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  24. package/docs/throughline-handoff-context.example.json +57 -0
  25. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  26. package/package.json +6 -2
  27. package/src/baton.mjs +17 -45
  28. package/src/baton.test.mjs +4 -41
  29. package/src/cli/codex-capture.mjs +95 -0
  30. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  31. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  32. package/src/cli/codex-handoff-smoke.mjs +163 -0
  33. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  34. package/src/cli/codex-handoff-start.mjs +291 -0
  35. package/src/cli/codex-handoff-start.test.mjs +194 -0
  36. package/src/cli/codex-hook.mjs +276 -0
  37. package/src/cli/codex-hook.test.mjs +293 -0
  38. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  39. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  40. package/src/cli/codex-restore-smoke.mjs +357 -0
  41. package/src/cli/codex-restore-source-audit.mjs +304 -0
  42. package/src/cli/codex-resume.mjs +138 -0
  43. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  44. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  45. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  46. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  47. package/src/cli/codex-summarize.mjs +224 -0
  48. package/src/cli/codex-threads.mjs +89 -0
  49. package/src/cli/codex-visibility-smoke.mjs +196 -0
  50. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  51. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  52. package/src/cli/doctor.mjs +503 -1
  53. package/src/cli/doctor.test.mjs +542 -3
  54. package/src/cli/handoff-preview.mjs +78 -0
  55. package/src/cli/help.test.mjs +64 -0
  56. package/src/cli/install.mjs +226 -3
  57. package/src/cli/install.test.mjs +205 -4
  58. package/src/cli/trim.mjs +564 -0
  59. package/src/codex-app-server.mjs +1816 -0
  60. package/src/codex-app-server.test.mjs +512 -0
  61. package/src/codex-auto-refresh.mjs +194 -0
  62. package/src/codex-auto-refresh.test.mjs +182 -0
  63. package/src/codex-capture.mjs +235 -0
  64. package/src/codex-capture.test.mjs +393 -0
  65. package/src/codex-handoff-model-smoke.mjs +114 -0
  66. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  67. package/src/codex-handoff-smoke.mjs +124 -0
  68. package/src/codex-handoff-smoke.test.mjs +103 -0
  69. package/src/codex-handoff.mjs +331 -0
  70. package/src/codex-handoff.test.mjs +220 -0
  71. package/src/codex-host-primitive-audit.mjs +374 -0
  72. package/src/codex-host-primitive-audit.test.mjs +208 -0
  73. package/src/codex-restore-smoke.test.mjs +639 -0
  74. package/src/codex-restore-source-audit.mjs +1348 -0
  75. package/src/codex-restore-source-audit.test.mjs +623 -0
  76. package/src/codex-resume.test.mjs +242 -0
  77. package/src/codex-rollout-memory.mjs +711 -0
  78. package/src/codex-rollout-memory.test.mjs +610 -0
  79. package/src/codex-sidecar-cli.test.mjs +75 -0
  80. package/src/codex-sidecar.mjs +246 -0
  81. package/src/codex-sidecar.test.mjs +172 -0
  82. package/src/codex-summarize.test.mjs +143 -0
  83. package/src/codex-thread-identity.mjs +23 -0
  84. package/src/codex-thread-index.mjs +173 -0
  85. package/src/codex-thread-index.test.mjs +164 -0
  86. package/src/codex-usage.mjs +110 -0
  87. package/src/codex-usage.test.mjs +140 -0
  88. package/src/codex-visibility-smoke.test.mjs +222 -0
  89. package/src/codex-vscode-restore-smoke.mjs +206 -0
  90. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  91. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  92. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  93. package/src/db-schema.test.mjs +96 -0
  94. package/src/db.mjs +14 -1
  95. package/src/haiku-summarizer.mjs +267 -26
  96. package/src/haiku-summarizer.test.mjs +282 -0
  97. package/src/handoff-preview.test.mjs +108 -0
  98. package/src/handoff-record.mjs +294 -0
  99. package/src/handoff-record.test.mjs +226 -0
  100. package/src/hook-entrypoints.test.mjs +286 -0
  101. package/src/package-files.test.mjs +19 -0
  102. package/src/prompt-submit.mjs +9 -6
  103. package/src/resume-context.mjs +58 -171
  104. package/src/resume-context.test.mjs +177 -0
  105. package/src/session-start.mjs +85 -26
  106. package/src/state-file.mjs +50 -6
  107. package/src/state-file.test.mjs +50 -0
  108. package/src/token-monitor.mjs +14 -10
  109. package/src/token-monitor.test.mjs +27 -0
  110. package/src/trim-cli.test.mjs +1584 -0
  111. package/src/trim-model.mjs +584 -0
  112. package/src/trim-model.test.mjs +568 -0
  113. package/src/turn-processor.mjs +17 -10
  114. package/src/vscode-task.mjs +33 -10
  115. package/src/vscode-task.test.mjs +19 -9
  116. package/src/cli/save-inflight.mjs +0 -81
@@ -0,0 +1,95 @@
1
+ import { getDb } from '../db.mjs';
2
+ import { captureCodexRolloutToDb } from '../codex-capture.mjs';
3
+ import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
4
+
5
+ function parseArgs(argv) {
6
+ const out = {
7
+ codexThreadId: null,
8
+ codexHome: null,
9
+ projectPath: process.cwd(),
10
+ json: false,
11
+ };
12
+
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const arg = argv[i];
15
+ if (arg === '--codex-thread-id') {
16
+ const value = argv[++i];
17
+ if (!value || value.startsWith('-')) throw new Error('--codex-thread-id requires an id');
18
+ out.codexThreadId = value;
19
+ } else if (arg === '--codex-home') {
20
+ const value = argv[++i];
21
+ if (!value || value.startsWith('-')) throw new Error('--codex-home requires a path');
22
+ out.codexHome = value;
23
+ } else if (arg === '--project') {
24
+ const value = argv[++i];
25
+ if (!value || value.startsWith('-')) throw new Error('--project requires a path');
26
+ out.projectPath = value;
27
+ } else if (arg === '--json') {
28
+ out.json = true;
29
+ } else {
30
+ throw new Error(`unknown argument: ${arg}`);
31
+ }
32
+ }
33
+
34
+ return out;
35
+ }
36
+
37
+ export async function run(argv) {
38
+ let parsed;
39
+ try {
40
+ parsed = parseArgs(argv);
41
+ } catch (err) {
42
+ const msg = err instanceof Error ? err.message : 'unknown';
43
+ process.stderr.write(`[codex-capture] ${msg}\n`);
44
+ process.exit(1);
45
+ }
46
+
47
+ const identity = resolveCodexThreadIdentity({ codexThreadId: parsed.codexThreadId }, process.env);
48
+ if (!identity.codexThreadId) {
49
+ process.stderr.write(
50
+ '[codex-capture] missing Codex thread id. Pass --codex-thread-id <id> or set THROUGHLINE_CODEX_THREAD_ID / CODEX_THREAD_ID.\n',
51
+ );
52
+ process.exit(1);
53
+ }
54
+
55
+ let result;
56
+ try {
57
+ result = captureCodexRolloutToDb(getDb(), {
58
+ threadId: identity.codexThreadId,
59
+ codexHome: parsed.codexHome ?? undefined,
60
+ projectPath: parsed.projectPath,
61
+ });
62
+ } catch (err) {
63
+ const msg = err instanceof Error ? err.message : 'unknown';
64
+ process.stderr.write(`[codex-capture] ${msg}\n`);
65
+ process.exit(1);
66
+ }
67
+
68
+ const output = {
69
+ ...result,
70
+ codexThreadIdSource: identity.codexThreadIdSource,
71
+ };
72
+
73
+ if (parsed.json) {
74
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
75
+ } else {
76
+ process.stdout.write(renderReport(output) + '\n');
77
+ }
78
+
79
+ process.exit(result.status === 'captured' ? 0 : 1);
80
+ }
81
+
82
+ function renderReport(result) {
83
+ const lines = [];
84
+ lines.push('## Throughline Codex Capture');
85
+ lines.push('');
86
+ lines.push(`Status: ${result.status}`);
87
+ if (result.reason) lines.push(`Reason: ${result.reason}`);
88
+ lines.push(`Codex thread: ${result.threadId}`);
89
+ lines.push(`Throughline session: ${result.sessionId}`);
90
+ lines.push(`Project: ${result.projectPath}`);
91
+ if (result.rolloutPath) lines.push(`Rollout: ${result.rolloutPath}`);
92
+ lines.push(`Captured turns: ${result.capturedTurns}`);
93
+ lines.push(`Captured rows: ${result.capturedRows}`);
94
+ return lines.join('\n');
95
+ }
@@ -0,0 +1,292 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { getDb } from '../db.mjs';
4
+ import { buildHandoffRecord } from '../handoff-record.mjs';
5
+ import { renderCodexNewThreadHandoff } from '../codex-handoff.mjs';
6
+ import { buildCodexHandoffSmoke } from '../codex-handoff-smoke.mjs';
7
+ import {
8
+ buildCodexHandoffModelSmokePrompt,
9
+ CODEX_HANDOFF_MODEL_SMOKE_ENV,
10
+ DEFAULT_CODEX_HANDOFF_MODEL_SMOKE_TIMEOUT_MS,
11
+ runCodexHandoffModelSmoke,
12
+ } from '../codex-handoff-model-smoke.mjs';
13
+
14
+ async function readStdin() {
15
+ let raw = '';
16
+ await new Promise((resolve) => {
17
+ process.stdin.setEncoding('utf8');
18
+ process.stdin.on('data', (chunk) => {
19
+ raw += chunk;
20
+ });
21
+ process.stdin.on('end', resolve);
22
+ });
23
+ return raw;
24
+ }
25
+
26
+ function parseNonNegativeInteger(args, index, flag) {
27
+ const value = Number(args[index]);
28
+ if (!Number.isInteger(value) || value < 0) {
29
+ throw new Error(`${flag} must be a non-negative integer`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function parsePositiveInteger(args, index, flag) {
35
+ const value = Number(args[index]);
36
+ if (!Number.isInteger(value) || value < 1) {
37
+ throw new Error(`${flag} must be a positive integer`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function parseArgs(args) {
43
+ const out = {
44
+ sessionId: null,
45
+ json: false,
46
+ dryRun: false,
47
+ printPrompt: false,
48
+ memoStdin: false,
49
+ marker: `TL_CODEX_HANDOFF_${randomUUID().slice(0, 8)}`,
50
+ codexCliBin: null,
51
+ timeoutMs: DEFAULT_CODEX_HANDOFF_MODEL_SMOKE_TIMEOUT_MS,
52
+ maxPromptChars: undefined,
53
+ maxDetailRefs: undefined,
54
+ maxRecentBodies: undefined,
55
+ maxBodyChars: undefined,
56
+ };
57
+
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i];
60
+ if (arg === '--session') {
61
+ const value = args[++i];
62
+ if (!value || value.startsWith('-')) {
63
+ throw new Error('--session requires a session id');
64
+ }
65
+ out.sessionId = value;
66
+ } else if (arg === '--dry-run') {
67
+ out.dryRun = true;
68
+ } else if (arg === '--print-prompt') {
69
+ out.printPrompt = true;
70
+ } else if (arg === '--memo-stdin') {
71
+ out.memoStdin = true;
72
+ } else if (arg === '--marker') {
73
+ const value = args[++i];
74
+ if (!value || value.startsWith('-')) {
75
+ throw new Error('--marker requires a marker string');
76
+ }
77
+ out.marker = value;
78
+ } else if (arg === '--codex-cli-bin') {
79
+ const value = args[++i];
80
+ if (!value || value.startsWith('-')) {
81
+ throw new Error('--codex-cli-bin requires a command path');
82
+ }
83
+ out.codexCliBin = value;
84
+ } else if (arg === '--timeout-ms') {
85
+ out.timeoutMs = parsePositiveInteger(args, ++i, '--timeout-ms');
86
+ } else if (arg === '--max-prompt-chars') {
87
+ out.maxPromptChars = parsePositiveInteger(args, ++i, '--max-prompt-chars');
88
+ } else if (arg === '--max-detail-refs') {
89
+ out.maxDetailRefs = parseNonNegativeInteger(args, ++i, '--max-detail-refs');
90
+ } else if (arg === '--max-recent-bodies') {
91
+ out.maxRecentBodies = parseNonNegativeInteger(args, ++i, '--max-recent-bodies');
92
+ } else if (arg === '--max-body-chars') {
93
+ out.maxBodyChars = parseNonNegativeInteger(args, ++i, '--max-body-chars');
94
+ } else if (arg === '--json') {
95
+ out.json = true;
96
+ } else if (!arg.startsWith('-') && !out.sessionId) {
97
+ out.sessionId = arg;
98
+ } else {
99
+ throw new Error(`unknown argument: ${arg}`);
100
+ }
101
+ }
102
+
103
+ return out;
104
+ }
105
+
106
+ function findLatestCodexSessionId(db, projectPath) {
107
+ const row = db
108
+ .prepare(
109
+ `SELECT session_id
110
+ FROM sessions
111
+ WHERE lower(project_path) = lower(?)
112
+ AND session_id LIKE 'codex:%'
113
+ ORDER BY updated_at DESC
114
+ LIMIT 1`,
115
+ )
116
+ .get(projectPath);
117
+ return row?.session_id ?? null;
118
+ }
119
+
120
+ function renderTextResult(result) {
121
+ const lines = [];
122
+ lines.push('throughline codex handoff model smoke');
123
+ lines.push('');
124
+ lines.push(` status: ${result.status}`);
125
+ lines.push(` reason: ${result.reason}`);
126
+ lines.push(` session: ${result.sessionId}`);
127
+ lines.push(` marker: ${result.marker}`);
128
+ lines.push(` handoff smoke: ${result.handoffSmoke.status}`);
129
+ lines.push(` prompt chars: ${result.handoffSmoke.promptChars}`);
130
+ if (result.modelPromptChars !== undefined) {
131
+ lines.push(` model prompt: ${result.modelPromptChars}`);
132
+ }
133
+ if (result.wouldRun !== undefined) {
134
+ lines.push(` would run: ${result.wouldRun ? 'yes' : 'no'}`);
135
+ }
136
+ lines.push(` marker visible: ${result.markerVisible ? 'yes' : 'no'}`);
137
+ if (result.commandPreview) {
138
+ lines.push(` command: ${result.commandPreview.join(' ')}`);
139
+ }
140
+ if (result.stdout) {
141
+ lines.push('');
142
+ lines.push('stdout:');
143
+ lines.push(result.stdout.trim());
144
+ }
145
+ if (result.stderr) {
146
+ lines.push('');
147
+ lines.push('stderr:');
148
+ lines.push(result.stderr.trim());
149
+ }
150
+ if (result.prompt) {
151
+ lines.push('');
152
+ lines.push(result.prompt);
153
+ }
154
+ return lines.join('\n');
155
+ }
156
+
157
+ export async function run(args) {
158
+ let parsed;
159
+ try {
160
+ parsed = parseArgs(args);
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : 'unknown';
163
+ process.stderr.write(`[codex-handoff-model-smoke] ${msg}\n`);
164
+ process.exit(1);
165
+ }
166
+
167
+ const command = parsed.codexCliBin ?? process.env.THROUGHLINE_CODEX_CLI_BIN ?? 'codex';
168
+ if (!parsed.dryRun && process.env[CODEX_HANDOFF_MODEL_SMOKE_ENV] !== '1') {
169
+ const result = {
170
+ status: 'refused',
171
+ reason: 'experimental_env_required',
172
+ requiredEnv: `${CODEX_HANDOFF_MODEL_SMOKE_ENV}=1`,
173
+ };
174
+ if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
175
+ else process.stderr.write(`[codex-handoff-model-smoke] ${result.reason}\n`);
176
+ process.exit(1);
177
+ }
178
+
179
+ const inflightMemo = parsed.memoStdin ? await readStdin() : null;
180
+ const db = getDb();
181
+ const sessionId = parsed.sessionId ?? findLatestCodexSessionId(db, process.cwd());
182
+ if (!sessionId) {
183
+ process.stderr.write(
184
+ '[codex-handoff-model-smoke] no Codex session found for this project. Pass --session codex:<thread-id> explicitly.\n',
185
+ );
186
+ process.exit(1);
187
+ }
188
+
189
+ const record = buildHandoffRecord(db, {
190
+ sessionId,
191
+ isInheritance: false,
192
+ inflightMemo,
193
+ });
194
+ if (!record) {
195
+ process.stderr.write(`[codex-handoff-model-smoke] no handoff memory found for session ${sessionId}\n`);
196
+ process.exit(1);
197
+ }
198
+
199
+ const handoffSmoke = buildCodexHandoffSmoke(record, {
200
+ maxPromptChars: parsed.maxPromptChars,
201
+ maxDetailRefs: parsed.maxDetailRefs,
202
+ maxRecentBodies: parsed.maxRecentBodies,
203
+ maxBodyChars: parsed.maxBodyChars,
204
+ });
205
+ if (handoffSmoke.status !== 'ready') {
206
+ const result = {
207
+ status: 'refused',
208
+ reason: 'handoff_smoke_not_ready',
209
+ sessionId,
210
+ handoffSmoke,
211
+ };
212
+ if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
213
+ else process.stdout.write(renderTextResult({ ...result, marker: parsed.marker, markerVisible: false }) + '\n');
214
+ process.exit(1);
215
+ }
216
+
217
+ const handoffPrompt = renderCodexNewThreadHandoff(record, {
218
+ maxDetailRefs: parsed.maxDetailRefs,
219
+ maxRecentBodies: parsed.maxRecentBodies,
220
+ maxBodyChars: parsed.maxBodyChars,
221
+ });
222
+ const prompt = buildCodexHandoffModelSmokePrompt({
223
+ handoffPrompt,
224
+ marker: parsed.marker,
225
+ });
226
+ const commandPreview = [
227
+ command,
228
+ 'exec',
229
+ '--ephemeral',
230
+ '--ignore-user-config',
231
+ '--ignore-rules',
232
+ '--skip-git-repo-check',
233
+ '--sandbox',
234
+ 'read-only',
235
+ '-C',
236
+ process.cwd(),
237
+ '<prompt>',
238
+ ];
239
+ if (parsed.dryRun) {
240
+ const result = {
241
+ status: 'dry-run',
242
+ reason: 'codex_exec_not_started',
243
+ sessionId,
244
+ marker: parsed.marker,
245
+ markerVisible: false,
246
+ handoffSmoke,
247
+ command,
248
+ commandPreview,
249
+ modelPromptChars: prompt.length,
250
+ estimatedModelPromptTokens: Math.ceil(prompt.length / 4),
251
+ proofScope: 'dry_run_only',
252
+ wouldRun: false,
253
+ mutatesCurrentThread: false,
254
+ prompt: parsed.printPrompt ? prompt : undefined,
255
+ };
256
+ if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
257
+ else process.stdout.write(renderTextResult(result) + '\n');
258
+ process.exit(0);
259
+ }
260
+ const smoke = runCodexHandoffModelSmoke({
261
+ prompt,
262
+ marker: parsed.marker,
263
+ cwd: process.cwd(),
264
+ command,
265
+ timeoutMs: parsed.timeoutMs,
266
+ env: {
267
+ ...process.env,
268
+ [CODEX_HANDOFF_MODEL_SMOKE_ENV]: process.env[CODEX_HANDOFF_MODEL_SMOKE_ENV],
269
+ },
270
+ });
271
+ const result = {
272
+ ...smoke,
273
+ sessionId,
274
+ handoffSmoke,
275
+ command,
276
+ commandPreview,
277
+ modelPromptChars: prompt.length,
278
+ estimatedModelPromptTokens: Math.ceil(prompt.length / 4),
279
+ proofScope: 'codex_exec_ephemeral_read_only',
280
+ wouldRun: true,
281
+ mutatesCurrentThread: false,
282
+ };
283
+
284
+ if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
285
+ else process.stdout.write(renderTextResult(result) + '\n');
286
+ process.exit(result.status === 'visible' ? 0 : 1);
287
+ }
288
+
289
+ export const _internal = {
290
+ parseArgs,
291
+ renderTextResult,
292
+ };
@@ -0,0 +1,262 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } 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(dirname(fileURLToPath(import.meta.url))));
10
+
11
+ function makeTempHome() {
12
+ return mkdtempSync(join(tmpdir(), 'tl-codex-handoff-model-home-'));
13
+ }
14
+
15
+ function makeTempProject() {
16
+ return mkdtempSync(join(tmpdir(), 'tl-codex-handoff-model-project-'));
17
+ }
18
+
19
+ function makeFakeCodexCli(dir) {
20
+ const script = join(dir, 'fake-codex-cli.mjs');
21
+ writeFileSync(
22
+ script,
23
+ `#!/usr/bin/env node
24
+ const prompt = process.argv.at(-1) ?? '';
25
+ if (!process.argv.includes('exec')) process.exit(7);
26
+ if (!process.argv.includes('--ephemeral')) process.exit(8);
27
+ if (!prompt.includes('latest handoff model body')) process.exit(9);
28
+ const marker = (prompt.match(/TL_CLI_HANDOFF_MODEL/) ?? [''])[0];
29
+ process.stdout.write(marker + '\\n');
30
+ `,
31
+ );
32
+ chmodSync(script, 0o755);
33
+ return script;
34
+ }
35
+
36
+ async function seedDb(home, project) {
37
+ const originalHome = process.env.HOME;
38
+ const originalUserProfile = process.env.USERPROFILE;
39
+ process.env.HOME = home;
40
+ process.env.USERPROFILE = home;
41
+ try {
42
+ const mod = await import(`../db.mjs?codexHandoffModel=${Date.now()}-${Math.random()}`);
43
+ const db = mod.getDb();
44
+ db.prepare(
45
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
46
+ VALUES ('codex:thread-handoff-model', ?, 'active', 1, 2)`,
47
+ ).run(project);
48
+ db.prepare(
49
+ `INSERT INTO bodies
50
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
51
+ VALUES ('codex:thread-handoff-model', 'codex:thread-handoff-model', 1, 'assistant',
52
+ 'latest handoff model body', 4, 1000)`,
53
+ ).run();
54
+ db.close();
55
+ } finally {
56
+ if (originalHome === undefined) delete process.env.HOME;
57
+ else process.env.HOME = originalHome;
58
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
59
+ else process.env.USERPROFILE = originalUserProfile;
60
+ }
61
+ }
62
+
63
+ function runSmoke(home, project, args = [], extraEnv = {}, input = undefined) {
64
+ return spawnSync(
65
+ process.execPath,
66
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'codex-handoff-model-smoke', ...args],
67
+ {
68
+ cwd: project,
69
+ env: {
70
+ ...process.env,
71
+ HOME: home,
72
+ USERPROFILE: home,
73
+ ...extraEnv,
74
+ },
75
+ encoding: 'utf8',
76
+ input,
77
+ },
78
+ );
79
+ }
80
+
81
+ test('codex-handoff-model-smoke refuses without explicit env', async () => {
82
+ const home = makeTempHome();
83
+ const project = makeTempProject();
84
+ try {
85
+ await seedDb(home, project);
86
+ const result = runSmoke(home, project, [
87
+ '--session',
88
+ 'codex:thread-handoff-model',
89
+ '--json',
90
+ ]);
91
+
92
+ assert.equal(result.status, 1);
93
+ const payload = JSON.parse(result.stdout);
94
+ assert.equal(payload.status, 'refused');
95
+ assert.equal(payload.reason, 'experimental_env_required');
96
+ } finally {
97
+ rmSync(project, { recursive: true, force: true });
98
+ rmSync(home, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test('codex-handoff-model-smoke dry-run does not require env or start Codex exec', async () => {
103
+ const home = makeTempHome();
104
+ const project = makeTempProject();
105
+ try {
106
+ await seedDb(home, project);
107
+ const result = runSmoke(home, project, [
108
+ '--session',
109
+ 'codex:thread-handoff-model',
110
+ '--marker',
111
+ 'TL_CLI_HANDOFF_MODEL_DRY_RUN',
112
+ '--dry-run',
113
+ '--json',
114
+ ]);
115
+
116
+ assert.equal(result.status, 0, result.stderr);
117
+ const payload = JSON.parse(result.stdout);
118
+ assert.equal(payload.status, 'dry-run');
119
+ assert.equal(payload.reason, 'codex_exec_not_started');
120
+ assert.equal(payload.sessionId, 'codex:thread-handoff-model');
121
+ assert.equal(payload.handoffSmoke.status, 'ready');
122
+ assert.equal(payload.wouldRun, false);
123
+ assert.equal(payload.mutatesCurrentThread, false);
124
+ assert.equal(payload.markerVisible, false);
125
+ assert.equal(payload.proofScope, 'dry_run_only');
126
+ assert.equal(payload.commandPreview.at(-1), '<prompt>');
127
+ assert.equal(payload.prompt, undefined);
128
+ } finally {
129
+ rmSync(project, { recursive: true, force: true });
130
+ rmSync(home, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ test('codex-handoff-model-smoke dry-run can print the exact model prompt', async () => {
135
+ const home = makeTempHome();
136
+ const project = makeTempProject();
137
+ try {
138
+ await seedDb(home, project);
139
+ const result = runSmoke(home, project, [
140
+ '--session',
141
+ 'codex:thread-handoff-model',
142
+ '--marker',
143
+ 'TL_CLI_HANDOFF_MODEL_PRINT',
144
+ '--dry-run',
145
+ '--print-prompt',
146
+ '--json',
147
+ ]);
148
+
149
+ assert.equal(result.status, 0, result.stderr);
150
+ const payload = JSON.parse(result.stdout);
151
+ assert.equal(payload.status, 'dry-run');
152
+ assert.match(payload.prompt, /## Throughline: New Codex Thread Handoff/);
153
+ assert.match(payload.prompt, /### Throughline Fresh-Thread Handoff Model Smoke/);
154
+ assert.match(payload.prompt, /TL_CLI_HANDOFF_MODEL_PRINT/);
155
+ assert.equal(payload.modelPromptChars, payload.prompt.length);
156
+ } finally {
157
+ rmSync(project, { recursive: true, force: true });
158
+ rmSync(home, { recursive: true, force: true });
159
+ }
160
+ });
161
+
162
+ test('codex-handoff-model-smoke dry-run accepts Codex-primary memo on stdin', async () => {
163
+ const home = makeTempHome();
164
+ const project = makeTempProject();
165
+ try {
166
+ await seedDb(home, project);
167
+ const result = runSmoke(
168
+ home,
169
+ project,
170
+ [
171
+ '--session',
172
+ 'codex:thread-handoff-model',
173
+ '--marker',
174
+ 'TL_CLI_HANDOFF_MODEL_MEMO',
175
+ '--dry-run',
176
+ '--memo-stdin',
177
+ '--print-prompt',
178
+ '--json',
179
+ ],
180
+ {},
181
+ '**Next move**: memo-visible model smoke',
182
+ );
183
+
184
+ assert.equal(result.status, 0, result.stderr);
185
+ const payload = JSON.parse(result.stdout);
186
+ assert.equal(payload.status, 'dry-run');
187
+ assert.match(payload.prompt, /In-flight Memo/);
188
+ assert.match(payload.prompt, /memo-visible model smoke/);
189
+ assert.match(payload.prompt, /TL_CLI_HANDOFF_MODEL_MEMO/);
190
+ } finally {
191
+ rmSync(project, { recursive: true, force: true });
192
+ rmSync(home, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ test('codex-handoff-model-smoke runs fake ephemeral Codex exec smoke', async () => {
197
+ const home = makeTempHome();
198
+ const project = makeTempProject();
199
+ try {
200
+ await seedDb(home, project);
201
+ const fake = makeFakeCodexCli(project);
202
+ const result = runSmoke(
203
+ home,
204
+ project,
205
+ [
206
+ '--session',
207
+ 'codex:thread-handoff-model',
208
+ '--marker',
209
+ 'TL_CLI_HANDOFF_MODEL',
210
+ '--codex-cli-bin',
211
+ fake,
212
+ '--json',
213
+ ],
214
+ { THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE: '1' },
215
+ );
216
+
217
+ assert.equal(result.status, 0, result.stderr);
218
+ const payload = JSON.parse(result.stdout);
219
+ assert.equal(payload.status, 'visible');
220
+ assert.equal(payload.reason, 'marker_found_in_codex_exec_output');
221
+ assert.equal(payload.sessionId, 'codex:thread-handoff-model');
222
+ assert.equal(payload.handoffSmoke.status, 'ready');
223
+ assert.equal(payload.proofScope, 'codex_exec_ephemeral_read_only');
224
+ assert.equal(payload.mutatesCurrentThread, false);
225
+ assert.match(payload.stdout, /TL_CLI_HANDOFF_MODEL/);
226
+ } finally {
227
+ rmSync(project, { recursive: true, force: true });
228
+ rmSync(home, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ test('codex-handoff-model-smoke refuses when structural handoff smoke is not ready', async () => {
233
+ const home = makeTempHome();
234
+ const project = makeTempProject();
235
+ try {
236
+ await seedDb(home, project);
237
+ const fake = makeFakeCodexCli(project);
238
+ const result = runSmoke(
239
+ home,
240
+ project,
241
+ [
242
+ '--session',
243
+ 'codex:thread-handoff-model',
244
+ '--max-prompt-chars',
245
+ '50',
246
+ '--codex-cli-bin',
247
+ fake,
248
+ '--json',
249
+ ],
250
+ { THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE: '1' },
251
+ );
252
+
253
+ assert.equal(result.status, 1);
254
+ const payload = JSON.parse(result.stdout);
255
+ assert.equal(payload.status, 'refused');
256
+ assert.equal(payload.reason, 'handoff_smoke_not_ready');
257
+ assert.equal(payload.handoffSmoke.status, 'not-ready');
258
+ } finally {
259
+ rmSync(project, { recursive: true, force: true });
260
+ rmSync(home, { recursive: true, force: true });
261
+ }
262
+ });