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,163 @@
1
+ import { getDb } from '../db.mjs';
2
+ import { buildHandoffRecord } from '../handoff-record.mjs';
3
+ import { buildCodexHandoffSmoke } from '../codex-handoff-smoke.mjs';
4
+
5
+ async function readStdin() {
6
+ let raw = '';
7
+ await new Promise((resolve) => {
8
+ process.stdin.setEncoding('utf8');
9
+ process.stdin.on('data', (chunk) => {
10
+ raw += chunk;
11
+ });
12
+ process.stdin.on('end', resolve);
13
+ });
14
+ return raw;
15
+ }
16
+
17
+ function parseNonNegativeInteger(args, index, flag) {
18
+ const value = Number(args[index]);
19
+ if (!Number.isInteger(value) || value < 0) {
20
+ throw new Error(`${flag} must be a non-negative integer`);
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function parsePositiveInteger(args, index, flag) {
26
+ const value = Number(args[index]);
27
+ if (!Number.isInteger(value) || value < 1) {
28
+ throw new Error(`${flag} must be a positive integer`);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function parseArgs(args) {
34
+ const out = {
35
+ sessionId: null,
36
+ json: false,
37
+ printPrompt: false,
38
+ memoStdin: false,
39
+ maxPromptChars: undefined,
40
+ maxDetailRefs: undefined,
41
+ maxRecentBodies: undefined,
42
+ maxBodyChars: undefined,
43
+ };
44
+
45
+ for (let i = 0; i < args.length; i++) {
46
+ const arg = args[i];
47
+ if (arg === '--session') {
48
+ const value = args[++i];
49
+ if (!value || value.startsWith('-')) {
50
+ throw new Error('--session requires a session id');
51
+ }
52
+ out.sessionId = value;
53
+ } else if (arg === '--json') {
54
+ out.json = true;
55
+ } else if (arg === '--print-prompt') {
56
+ out.printPrompt = true;
57
+ } else if (arg === '--memo-stdin') {
58
+ out.memoStdin = true;
59
+ } else if (arg === '--max-prompt-chars') {
60
+ out.maxPromptChars = parsePositiveInteger(args, ++i, '--max-prompt-chars');
61
+ } else if (arg === '--max-detail-refs') {
62
+ out.maxDetailRefs = parseNonNegativeInteger(args, ++i, '--max-detail-refs');
63
+ } else if (arg === '--max-recent-bodies') {
64
+ out.maxRecentBodies = parseNonNegativeInteger(args, ++i, '--max-recent-bodies');
65
+ } else if (arg === '--max-body-chars') {
66
+ out.maxBodyChars = parseNonNegativeInteger(args, ++i, '--max-body-chars');
67
+ } else if (!arg.startsWith('-') && !out.sessionId) {
68
+ out.sessionId = arg;
69
+ } else {
70
+ throw new Error(`unknown argument: ${arg}`);
71
+ }
72
+ }
73
+
74
+ return out;
75
+ }
76
+
77
+ function findLatestCodexSessionId(db, projectPath) {
78
+ const row = db
79
+ .prepare(
80
+ `SELECT session_id
81
+ FROM sessions
82
+ WHERE lower(project_path) = lower(?)
83
+ AND session_id LIKE 'codex:%'
84
+ ORDER BY updated_at DESC
85
+ LIMIT 1`,
86
+ )
87
+ .get(projectPath);
88
+ return row?.session_id ?? null;
89
+ }
90
+
91
+ function renderTextResult(result) {
92
+ const lines = [];
93
+ lines.push('throughline codex handoff smoke');
94
+ lines.push('');
95
+ lines.push(` status: ${result.status}`);
96
+ lines.push(` reason: ${result.reason}`);
97
+ lines.push(` session: ${result.sessionId}`);
98
+ lines.push(` source agent: ${result.sourceAgent}`);
99
+ lines.push(` prompt chars: ${result.promptChars}/${result.maxPromptChars}`);
100
+ lines.push(` estimated tokens: ${result.estimatedTokens}`);
101
+ lines.push(` L1 summaries: ${result.l1Summaries}`);
102
+ lines.push(` recent L2 bodies: ${result.recentBodies}`);
103
+ lines.push(` L3 references: ${result.l3References}`);
104
+ lines.push(` rendered detail cmds: ${result.renderedDetailCommands}`);
105
+ lines.push('');
106
+ lines.push(' checks:');
107
+ for (const check of result.checks) {
108
+ lines.push(` - ${check.id}: ${check.status}`);
109
+ }
110
+ if (result.prompt) {
111
+ lines.push('');
112
+ lines.push(result.prompt);
113
+ }
114
+ return lines.join('\n');
115
+ }
116
+
117
+ export async function run(args) {
118
+ let parsed;
119
+ try {
120
+ parsed = parseArgs(args);
121
+ } catch (err) {
122
+ const msg = err instanceof Error ? err.message : 'unknown';
123
+ process.stderr.write(`[codex-handoff-smoke] ${msg}\n`);
124
+ process.exit(1);
125
+ }
126
+
127
+ const inflightMemo = parsed.memoStdin ? await readStdin() : null;
128
+ const db = getDb();
129
+ const sessionId = parsed.sessionId ?? findLatestCodexSessionId(db, process.cwd());
130
+ if (!sessionId) {
131
+ process.stderr.write(
132
+ '[codex-handoff-smoke] no Codex session found for this project. Pass --session codex:<thread-id> explicitly.\n',
133
+ );
134
+ process.exit(1);
135
+ }
136
+
137
+ const record = buildHandoffRecord(db, {
138
+ sessionId,
139
+ isInheritance: false,
140
+ inflightMemo,
141
+ });
142
+ if (!record) {
143
+ process.stderr.write(`[codex-handoff-smoke] no handoff memory found for session ${sessionId}\n`);
144
+ process.exit(1);
145
+ }
146
+
147
+ const result = buildCodexHandoffSmoke(record, {
148
+ maxPromptChars: parsed.maxPromptChars,
149
+ maxDetailRefs: parsed.maxDetailRefs,
150
+ maxRecentBodies: parsed.maxRecentBodies,
151
+ maxBodyChars: parsed.maxBodyChars,
152
+ includePrompt: parsed.printPrompt,
153
+ });
154
+
155
+ if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
156
+ else process.stdout.write(renderTextResult(result) + '\n');
157
+ process.exit(result.status === 'ready' ? 0 : 1);
158
+ }
159
+
160
+ export const _internal = {
161
+ parseArgs,
162
+ renderTextResult,
163
+ };
@@ -0,0 +1,149 @@
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(dirname(fileURLToPath(import.meta.url))));
10
+
11
+ function makeTempHome() {
12
+ return mkdtempSync(join(tmpdir(), 'tl-codex-handoff-smoke-home-'));
13
+ }
14
+
15
+ function makeTempProject() {
16
+ return mkdtempSync(join(tmpdir(), 'tl-codex-handoff-smoke-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?codexHandoffSmoke=${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 ('codex:thread-handoff-smoke', ?, 'active', 1, 2)`,
30
+ ).run(project);
31
+ db.prepare(
32
+ `INSERT INTO skeletons
33
+ (session_id, origin_session_id, turn_number, role, summary, created_at)
34
+ VALUES ('codex:thread-handoff-smoke', 'codex:thread-handoff-smoke', 1, 'assistant',
35
+ 'older handoff smoke summary', 1000)`,
36
+ ).run();
37
+ db.prepare(
38
+ `INSERT INTO bodies
39
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
40
+ VALUES ('codex:thread-handoff-smoke', 'codex:thread-handoff-smoke', 2, 'assistant',
41
+ 'latest handoff smoke body', 4, 2000)`,
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 runSmoke(home, project, args = [], input = undefined) {
53
+ return spawnSync(
54
+ process.execPath,
55
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'codex-handoff-smoke', ...args],
56
+ {
57
+ cwd: project,
58
+ env: {
59
+ ...process.env,
60
+ HOME: home,
61
+ USERPROFILE: home,
62
+ },
63
+ encoding: 'utf8',
64
+ input,
65
+ },
66
+ );
67
+ }
68
+
69
+ test('codex-handoff-smoke validates latest Codex session as JSON', async () => {
70
+ const home = makeTempHome();
71
+ const project = makeTempProject();
72
+ try {
73
+ await seedDb(home, project);
74
+ const result = runSmoke(home, project, ['--json']);
75
+
76
+ assert.equal(result.status, 0, result.stderr);
77
+ const payload = JSON.parse(result.stdout);
78
+ assert.equal(payload.status, 'ready');
79
+ assert.equal(payload.sessionId, 'codex:thread-handoff-smoke');
80
+ assert.equal(payload.reason, 'fresh_thread_handoff_prompt_ready');
81
+ assert.equal(payload.checks.every((check) => check.status === 'pass'), true);
82
+ assert.equal(payload.prompt, undefined);
83
+ } finally {
84
+ rmSync(project, { recursive: true, force: true });
85
+ rmSync(home, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ test('codex-handoff-smoke can print the generated prompt', async () => {
90
+ const home = makeTempHome();
91
+ const project = makeTempProject();
92
+ try {
93
+ await seedDb(home, project);
94
+ const result = runSmoke(home, project, [
95
+ '--session',
96
+ 'codex:thread-handoff-smoke',
97
+ '--print-prompt',
98
+ ]);
99
+
100
+ assert.equal(result.status, 0, result.stderr);
101
+ assert.match(result.stdout, /throughline codex handoff smoke/);
102
+ assert.match(result.stdout, /status:\s+ready/);
103
+ assert.match(result.stdout, /Throughline: New Codex Thread Handoff/);
104
+ assert.match(result.stdout, /latest handoff smoke body/);
105
+ } finally {
106
+ rmSync(project, { recursive: true, force: true });
107
+ rmSync(home, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ test('codex-handoff-smoke exits nonzero when prompt size check fails', async () => {
112
+ const home = makeTempHome();
113
+ const project = makeTempProject();
114
+ try {
115
+ await seedDb(home, project);
116
+ const result = runSmoke(home, project, [
117
+ '--session',
118
+ 'codex:thread-handoff-smoke',
119
+ '--max-prompt-chars',
120
+ '50',
121
+ '--json',
122
+ ]);
123
+
124
+ assert.equal(result.status, 1);
125
+ const payload = JSON.parse(result.stdout);
126
+ assert.equal(payload.status, 'not-ready');
127
+ assert.equal(
128
+ payload.checks.find((check) => check.id === 'prompt_size_within_limit')?.status,
129
+ 'fail',
130
+ );
131
+ } finally {
132
+ rmSync(project, { recursive: true, force: true });
133
+ rmSync(home, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ test('codex-handoff-smoke rejects invalid limits', () => {
138
+ const home = makeTempHome();
139
+ const project = makeTempProject();
140
+ try {
141
+ const result = runSmoke(home, project, ['--max-prompt-chars', '0']);
142
+
143
+ assert.equal(result.status, 1);
144
+ assert.match(result.stderr, /--max-prompt-chars must be a positive integer/);
145
+ } finally {
146
+ rmSync(project, { recursive: true, force: true });
147
+ rmSync(home, { recursive: true, force: true });
148
+ }
149
+ });
@@ -0,0 +1,291 @@
1
+ import { getDb } from '../db.mjs';
2
+ import { buildHandoffRecord } from '../handoff-record.mjs';
3
+ import { renderCodexNewThreadHandoff } from '../codex-handoff.mjs';
4
+ import { buildCodexHandoffSmoke } from '../codex-handoff-smoke.mjs';
5
+ import { buildCodexHandoffModelSmokePrompt } from '../codex-handoff-model-smoke.mjs';
6
+ import { estimateTokens } from '../token-estimator.mjs';
7
+
8
+ async function readStdin() {
9
+ let raw = '';
10
+ await new Promise((resolve) => {
11
+ process.stdin.setEncoding('utf8');
12
+ process.stdin.on('data', (chunk) => {
13
+ raw += chunk;
14
+ });
15
+ process.stdin.on('end', resolve);
16
+ });
17
+ return raw;
18
+ }
19
+
20
+ function parseNonNegativeInteger(args, index, flag) {
21
+ const value = Number(args[index]);
22
+ if (!Number.isInteger(value) || value < 0) {
23
+ throw new Error(`${flag} must be a non-negative integer`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ function parsePositiveInteger(args, index, flag) {
29
+ const value = Number(args[index]);
30
+ if (!Number.isInteger(value) || value < 1) {
31
+ throw new Error(`${flag} must be a positive integer`);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function parseArgs(args) {
37
+ const out = {
38
+ sessionId: null,
39
+ json: false,
40
+ printPrompt: false,
41
+ memoStdin: false,
42
+ maxPromptChars: undefined,
43
+ maxDetailRefs: undefined,
44
+ maxRecentBodies: undefined,
45
+ maxBodyChars: undefined,
46
+ };
47
+
48
+ for (let i = 0; i < args.length; i++) {
49
+ const arg = args[i];
50
+ if (arg === '--session') {
51
+ const value = args[++i];
52
+ if (!value || value.startsWith('-')) {
53
+ throw new Error('--session requires a session id');
54
+ }
55
+ out.sessionId = value;
56
+ } else if (arg === '--json') {
57
+ out.json = true;
58
+ } else if (arg === '--print-prompt') {
59
+ out.printPrompt = true;
60
+ } else if (arg === '--memo-stdin') {
61
+ out.memoStdin = true;
62
+ } else if (arg === '--max-prompt-chars') {
63
+ out.maxPromptChars = parsePositiveInteger(args, ++i, '--max-prompt-chars');
64
+ } else if (arg === '--max-detail-refs') {
65
+ out.maxDetailRefs = parseNonNegativeInteger(args, ++i, '--max-detail-refs');
66
+ } else if (arg === '--max-recent-bodies') {
67
+ out.maxRecentBodies = parseNonNegativeInteger(args, ++i, '--max-recent-bodies');
68
+ } else if (arg === '--max-body-chars') {
69
+ out.maxBodyChars = parseNonNegativeInteger(args, ++i, '--max-body-chars');
70
+ } else if (!arg.startsWith('-') && !out.sessionId) {
71
+ out.sessionId = arg;
72
+ } else {
73
+ throw new Error(`unknown argument: ${arg}`);
74
+ }
75
+ }
76
+
77
+ return out;
78
+ }
79
+
80
+ function findLatestCodexSessionId(db, projectPath) {
81
+ const row = db
82
+ .prepare(
83
+ `SELECT session_id
84
+ FROM sessions
85
+ WHERE lower(project_path) = lower(?)
86
+ AND session_id LIKE 'codex:%'
87
+ ORDER BY updated_at DESC
88
+ LIMIT 1`,
89
+ )
90
+ .get(projectPath);
91
+ return row?.session_id ?? null;
92
+ }
93
+
94
+ function limitArgs(parsed, { includeMaxPromptChars = false } = {}) {
95
+ const args = [];
96
+ if (includeMaxPromptChars && parsed.maxPromptChars !== undefined) {
97
+ args.push('--max-prompt-chars', String(parsed.maxPromptChars));
98
+ }
99
+ if (parsed.maxDetailRefs !== undefined) {
100
+ args.push('--max-detail-refs', String(parsed.maxDetailRefs));
101
+ }
102
+ if (parsed.maxRecentBodies !== undefined) {
103
+ args.push('--max-recent-bodies', String(parsed.maxRecentBodies));
104
+ }
105
+ if (parsed.maxBodyChars !== undefined) {
106
+ args.push('--max-body-chars', String(parsed.maxBodyChars));
107
+ }
108
+ return args;
109
+ }
110
+
111
+ function memoArgs(parsed) {
112
+ return parsed.memoStdin ? ['--memo-stdin'] : [];
113
+ }
114
+
115
+ function commandFor(parts) {
116
+ return parts.join(' ');
117
+ }
118
+
119
+ function buildGuidance({ sessionId, parsed, handoffSmoke, handoffPrompt }) {
120
+ const smokeArgs = limitArgs(parsed, { includeMaxPromptChars: true });
121
+ const handoffArgs = limitArgs(parsed);
122
+ const modelPrompt = buildCodexHandoffModelSmokePrompt({
123
+ handoffPrompt,
124
+ marker: 'TL_CODEX_HANDOFF_START_SMOKE',
125
+ });
126
+ const commands = {
127
+ structuralSmoke: commandFor([
128
+ 'throughline',
129
+ 'codex-handoff-smoke',
130
+ '--session',
131
+ sessionId,
132
+ ...smokeArgs,
133
+ ...memoArgs(parsed),
134
+ '--json',
135
+ ]),
136
+ modelSmokeDryRun: commandFor([
137
+ 'throughline',
138
+ 'codex-handoff-model-smoke',
139
+ '--session',
140
+ sessionId,
141
+ ...smokeArgs,
142
+ ...memoArgs(parsed),
143
+ '--dry-run',
144
+ '--json',
145
+ ]),
146
+ renderPrompt: commandFor([
147
+ 'throughline',
148
+ 'codex-resume',
149
+ '--session',
150
+ sessionId,
151
+ '--format',
152
+ 'handoff',
153
+ ...handoffArgs,
154
+ ...memoArgs(parsed),
155
+ ]),
156
+ liveModelSmoke: commandFor([
157
+ 'THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE=1',
158
+ 'throughline',
159
+ 'codex-handoff-model-smoke',
160
+ '--session',
161
+ sessionId,
162
+ ...smokeArgs,
163
+ ...memoArgs(parsed),
164
+ '--json',
165
+ ]),
166
+ };
167
+ const ready = handoffSmoke.status === 'ready';
168
+ return {
169
+ status: ready ? 'ready' : 'not-ready',
170
+ reason: ready ? 'fresh_thread_handoff_start_ready' : 'handoff_smoke_not_ready',
171
+ sessionId,
172
+ mutatesCurrentThread: false,
173
+ startThreadManually: true,
174
+ handoffSmoke,
175
+ modelPromptChars: modelPrompt.length,
176
+ estimatedModelPromptTokens: estimateTokens(modelPrompt),
177
+ memoStdin: parsed.memoStdin,
178
+ memoReplayNote: parsed.memoStdin
179
+ ? 'Commands include --memo-stdin; pipe the same memo when replaying them.'
180
+ : null,
181
+ commands,
182
+ steps: ready
183
+ ? [
184
+ 'Run the structural smoke command if you want to re-check the handoff prompt.',
185
+ 'Run the model smoke dry-run command if you want to inspect the Codex exec boundary without starting a model turn.',
186
+ 'Render the handoff prompt with the render prompt command, or pass --print-prompt to this command.',
187
+ ...(parsed.memoStdin
188
+ ? ['When replaying individual commands, pipe the same memo because they include --memo-stdin.']
189
+ : []),
190
+ 'Start a new Codex thread and provide the handoff prompt as the opening context.',
191
+ ]
192
+ : [
193
+ 'Fix the failing handoff smoke checks before starting a new Codex thread.',
194
+ 'This handoff command does not run current-thread trim; use trim --execute --host codex for guarded current-thread rollback / inject.',
195
+ ],
196
+ };
197
+ }
198
+
199
+ function renderTextResult(result) {
200
+ const lines = [];
201
+ lines.push('throughline codex handoff start');
202
+ lines.push('');
203
+ lines.push(` status: ${result.status}`);
204
+ lines.push(` reason: ${result.reason}`);
205
+ lines.push(` session: ${result.sessionId}`);
206
+ lines.push(` mutates thread: ${result.mutatesCurrentThread ? 'yes' : 'no'}`);
207
+ lines.push(` handoff smoke: ${result.handoffSmoke.status}`);
208
+ lines.push(` prompt chars: ${result.handoffSmoke.promptChars}/${result.handoffSmoke.maxPromptChars}`);
209
+ lines.push(` model prompt: ${result.modelPromptChars}`);
210
+ if (result.memoReplayNote) {
211
+ lines.push(` memo replay: ${result.memoReplayNote}`);
212
+ }
213
+ lines.push('');
214
+ lines.push(' commands:');
215
+ lines.push(` structural smoke: ${result.commands.structuralSmoke}`);
216
+ lines.push(` model smoke dry-run: ${result.commands.modelSmokeDryRun}`);
217
+ lines.push(` render prompt: ${result.commands.renderPrompt}`);
218
+ lines.push(` live model smoke: ${result.commands.liveModelSmoke}`);
219
+ lines.push('');
220
+ lines.push(' steps:');
221
+ for (const step of result.steps) {
222
+ lines.push(` - ${step}`);
223
+ }
224
+ if (result.prompt) {
225
+ lines.push('');
226
+ lines.push(result.prompt);
227
+ }
228
+ return lines.join('\n');
229
+ }
230
+
231
+ export async function run(args) {
232
+ let parsed;
233
+ try {
234
+ parsed = parseArgs(args);
235
+ } catch (err) {
236
+ const msg = err instanceof Error ? err.message : 'unknown';
237
+ process.stderr.write(`[codex-handoff-start] ${msg}\n`);
238
+ process.exit(1);
239
+ }
240
+
241
+ const inflightMemo = parsed.memoStdin ? await readStdin() : null;
242
+ const db = getDb();
243
+ const sessionId = parsed.sessionId ?? findLatestCodexSessionId(db, process.cwd());
244
+ if (!sessionId) {
245
+ process.stderr.write(
246
+ '[codex-handoff-start] no Codex session found for this project. Pass --session codex:<thread-id> explicitly.\n',
247
+ );
248
+ process.exit(1);
249
+ }
250
+
251
+ const record = buildHandoffRecord(db, {
252
+ sessionId,
253
+ isInheritance: false,
254
+ inflightMemo,
255
+ });
256
+ if (!record) {
257
+ process.stderr.write(`[codex-handoff-start] no handoff memory found for session ${sessionId}\n`);
258
+ process.exit(1);
259
+ }
260
+
261
+ const smokeOptions = {
262
+ maxPromptChars: parsed.maxPromptChars,
263
+ maxDetailRefs: parsed.maxDetailRefs,
264
+ maxRecentBodies: parsed.maxRecentBodies,
265
+ maxBodyChars: parsed.maxBodyChars,
266
+ };
267
+ const handoffSmoke = buildCodexHandoffSmoke(record, smokeOptions);
268
+ const handoffPrompt = renderCodexNewThreadHandoff(record, {
269
+ maxDetailRefs: parsed.maxDetailRefs,
270
+ maxRecentBodies: parsed.maxRecentBodies,
271
+ maxBodyChars: parsed.maxBodyChars,
272
+ });
273
+ const result = buildGuidance({
274
+ sessionId,
275
+ parsed,
276
+ handoffSmoke,
277
+ handoffPrompt,
278
+ });
279
+ if (parsed.printPrompt) {
280
+ result.prompt = handoffPrompt;
281
+ }
282
+
283
+ if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
284
+ else process.stdout.write(renderTextResult(result) + '\n');
285
+ process.exit(result.status === 'ready' ? 0 : 1);
286
+ }
287
+
288
+ export const _internal = {
289
+ parseArgs,
290
+ renderTextResult,
291
+ };