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,124 @@
1
+ import { renderCodexNewThreadHandoff } from './codex-handoff.mjs';
2
+ import { estimateTokens } from './token-estimator.mjs';
3
+
4
+ export const DEFAULT_CODEX_HANDOFF_MAX_PROMPT_CHARS = 12_000;
5
+
6
+ function assertMaxPromptChars(value) {
7
+ if (!Number.isInteger(value) || value < 1) {
8
+ throw new Error('maxPromptChars must be a positive integer');
9
+ }
10
+ }
11
+
12
+ function addCheck(checks, { id, status, reason }) {
13
+ checks.push({ id, status, reason });
14
+ }
15
+
16
+ function findDetailCommands(text) {
17
+ const matches = text.match(/throughline detail \d\d:\d\d:\d\d/g) ?? [];
18
+ return matches;
19
+ }
20
+
21
+ export function buildCodexHandoffSmoke(
22
+ record,
23
+ {
24
+ maxPromptChars = DEFAULT_CODEX_HANDOFF_MAX_PROMPT_CHARS,
25
+ maxDetailRefs,
26
+ maxRecentBodies,
27
+ maxBodyChars,
28
+ includePrompt = false,
29
+ } = {},
30
+ ) {
31
+ if (!record) {
32
+ throw new Error('buildCodexHandoffSmoke: record is required');
33
+ }
34
+ assertMaxPromptChars(maxPromptChars);
35
+
36
+ const prompt = renderCodexNewThreadHandoff(record, {
37
+ maxDetailRefs,
38
+ maxRecentBodies,
39
+ maxBodyChars,
40
+ });
41
+ const checks = [];
42
+ const detailCommands = findDetailCommands(prompt);
43
+ const uniqueDetailCommands = new Set(detailCommands);
44
+
45
+ addCheck(checks, {
46
+ id: 'new_thread_handoff_header',
47
+ status: prompt.includes('## Throughline: New Codex Thread Handoff') ? 'pass' : 'fail',
48
+ reason: 'prompt must use the fresh-thread handoff header',
49
+ });
50
+ addCheck(checks, {
51
+ id: 'current_task_reading_contract',
52
+ status: /current-task context/.test(prompt) ? 'pass' : 'fail',
53
+ reason: 'prompt must frame memory as current-task context',
54
+ });
55
+ addCheck(checks, {
56
+ id: 'source_session_present',
57
+ status: prompt.includes(`Throughline session: ${record.session.id}`) ? 'pass' : 'fail',
58
+ reason: 'prompt must name the source Throughline session',
59
+ });
60
+ addCheck(checks, {
61
+ id: 'start_instruction_present',
62
+ status: prompt.includes('### Start Instruction') ? 'pass' : 'fail',
63
+ reason: 'prompt must include an explicit start instruction',
64
+ });
65
+ addCheck(checks, {
66
+ id: 'current_thread_not_mutated',
67
+ status: /Do not mutate the original Codex thread/.test(prompt) ? 'pass' : 'fail',
68
+ reason: 'prompt must preserve the current-thread mutation boundary',
69
+ });
70
+ addCheck(checks, {
71
+ id: 'active_l2_surface_present',
72
+ status:
73
+ record.memory.recentBodies.length === 0 || prompt.includes('### Recent Active Thread (L2)')
74
+ ? 'pass'
75
+ : 'fail',
76
+ reason: 'prompt must include recent L2 when the record has active bodies',
77
+ });
78
+ addCheck(checks, {
79
+ id: 'not_developer_message_json',
80
+ status: /"role"\s*:\s*"developer"/.test(prompt) ? 'fail' : 'pass',
81
+ reason: 'fresh-thread prompt must not be a developer-message JSON item',
82
+ });
83
+ addCheck(checks, {
84
+ id: 'not_full_active_work_renderer',
85
+ status: prompt.includes('## Throughline: Active Work Context') ? 'fail' : 'pass',
86
+ reason: 'fresh-thread smoke must not accidentally use the full active-work renderer',
87
+ });
88
+ addCheck(checks, {
89
+ id: 'detail_commands_deduplicated',
90
+ status: detailCommands.length === uniqueDetailCommands.size ? 'pass' : 'fail',
91
+ reason: 'handoff prompt should not repeat the same detail command',
92
+ });
93
+ addCheck(checks, {
94
+ id: 'prompt_size_within_limit',
95
+ status: prompt.length <= maxPromptChars ? 'pass' : 'fail',
96
+ reason: `prompt must be at or below ${maxPromptChars} chars`,
97
+ });
98
+
99
+ const failing = checks.filter((check) => check.status !== 'pass');
100
+ const result = {
101
+ status: failing.length === 0 ? 'ready' : 'not-ready',
102
+ reason:
103
+ failing.length === 0
104
+ ? 'fresh_thread_handoff_prompt_ready'
105
+ : 'fresh_thread_handoff_prompt_failed_checks',
106
+ sessionId: record.session.id,
107
+ sourceAgent: record.source.adapter,
108
+ promptChars: prompt.length,
109
+ maxPromptChars,
110
+ estimatedTokens: estimateTokens(prompt),
111
+ l1Summaries: record.memory.l1Summaries.length,
112
+ recentBodies: record.memory.recentBodies.length,
113
+ l3References: record.references.l3.length,
114
+ renderedDetailCommands: detailCommands.length,
115
+ uniqueRenderedDetailCommands: uniqueDetailCommands.size,
116
+ checks,
117
+ };
118
+
119
+ if (includePrompt) {
120
+ result.prompt = prompt;
121
+ }
122
+
123
+ return result;
124
+ }
@@ -0,0 +1,103 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildCodexHandoffSmoke } from './codex-handoff-smoke.mjs';
5
+
6
+ function makeRecord({ bodyText = 'latest body', detailRefs = [] } = {}) {
7
+ return {
8
+ kind: 'handoff_record',
9
+ version: 1,
10
+ session: {
11
+ id: 'codex:thread-smoke',
12
+ projectPath: '/repo',
13
+ status: 'active',
14
+ mergedInto: null,
15
+ },
16
+ source: {
17
+ adapter: 'codex',
18
+ inheritance: false,
19
+ excludeOriginId: null,
20
+ originSessionIds: ['codex:thread-smoke'],
21
+ },
22
+ intent: 'continue implementation',
23
+ constraints: ['preserve user instructions'],
24
+ memory: {
25
+ inflightMemo: null,
26
+ latestThinking: [],
27
+ l1Summaries: [{ time: '12:00:01', role: 'assistant', summary: 'older summary' }],
28
+ recentBodies: [{ time: '12:00:02', role: 'assistant', text: bodyText }],
29
+ },
30
+ references: {
31
+ l3: detailRefs,
32
+ },
33
+ stats: {
34
+ l1Rows: 1,
35
+ l2Rows: 1,
36
+ thinkingRows: 0,
37
+ l3References: detailRefs.length,
38
+ preservedContextRows: 2,
39
+ },
40
+ };
41
+ }
42
+
43
+ test('buildCodexHandoffSmoke: validates a fresh-thread handoff prompt', () => {
44
+ const result = buildCodexHandoffSmoke(makeRecord(), { includePrompt: true });
45
+
46
+ assert.equal(result.status, 'ready');
47
+ assert.equal(result.reason, 'fresh_thread_handoff_prompt_ready');
48
+ assert.equal(result.sessionId, 'codex:thread-smoke');
49
+ assert.equal(result.sourceAgent, 'codex');
50
+ assert.equal(result.l1Summaries, 1);
51
+ assert.equal(result.recentBodies, 1);
52
+ assert.ok(result.promptChars > 0);
53
+ assert.ok(result.estimatedTokens > 0);
54
+ assert.match(result.prompt, /Throughline: New Codex Thread Handoff/);
55
+ assert.match(result.prompt, /Do not mutate the original Codex thread/);
56
+ assert.equal(result.checks.every((check) => check.status === 'pass'), true);
57
+ });
58
+
59
+ test('buildCodexHandoffSmoke: fails when prompt exceeds max size', () => {
60
+ const result = buildCodexHandoffSmoke(makeRecord({ bodyText: 'x'.repeat(200) }), {
61
+ maxPromptChars: 100,
62
+ });
63
+
64
+ assert.equal(result.status, 'not-ready');
65
+ assert.equal(
66
+ result.checks.find((check) => check.id === 'prompt_size_within_limit')?.status,
67
+ 'fail',
68
+ );
69
+ });
70
+
71
+ test('buildCodexHandoffSmoke: reports rendered detail command deduplication', () => {
72
+ const detailRefs = [
73
+ {
74
+ kind: 'tool_input',
75
+ toolName: 'exec_command',
76
+ sourceId: 'tool-1',
77
+ originSessionId: 'codex:thread-smoke',
78
+ turnNumber: 1,
79
+ createdAt: 1000,
80
+ detailCommand: 'throughline detail 12:00:01',
81
+ },
82
+ {
83
+ kind: 'tool_output',
84
+ toolName: 'exec_command',
85
+ sourceId: 'tool-2',
86
+ originSessionId: 'codex:thread-smoke',
87
+ turnNumber: 1,
88
+ createdAt: 1000,
89
+ detailCommand: 'throughline detail 12:00:01',
90
+ },
91
+ ];
92
+
93
+ const result = buildCodexHandoffSmoke(makeRecord({ detailRefs }), { includePrompt: true });
94
+
95
+ assert.equal(result.status, 'ready');
96
+ assert.equal(result.l3References, 2);
97
+ assert.equal(result.renderedDetailCommands, 1);
98
+ assert.equal(result.uniqueRenderedDetailCommands, 1);
99
+ assert.equal(
100
+ result.checks.find((check) => check.id === 'detail_commands_deduplicated')?.status,
101
+ 'pass',
102
+ );
103
+ });
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Codex-facing projection for Throughline handoff records.
3
+ *
4
+ * This is a plain JSON context block. It does not call codex-sidecar and does
5
+ * not infer host capabilities; Phase 5 diagnostics decide whether a sidecar
6
+ * can consume it.
7
+ */
8
+
9
+ export const THROUGHLINE_HANDOFF_SCHEMA_VERSION = 1;
10
+ export const DEFAULT_CODEX_HANDOFF_DETAIL_REF_LIMIT = 20;
11
+ export const DEFAULT_CODEX_HANDOFF_RECENT_BODY_LIMIT = 8;
12
+ export const DEFAULT_CODEX_HANDOFF_BODY_MAX_CHARS = 1_600;
13
+
14
+ function firstNonEmptyLine(text) {
15
+ if (!text) return null;
16
+ return text
17
+ .split('\n')
18
+ .map((line) => line.trim())
19
+ .find(Boolean) ?? null;
20
+ }
21
+
22
+ function summarizeRecord(record) {
23
+ const memoLine = firstNonEmptyLine(record.memory?.inflightMemo);
24
+ if (memoLine) return `In-flight handoff: ${memoLine}`;
25
+
26
+ const thinkingLine = firstNonEmptyLine(record.memory?.latestThinking?.[0]?.text);
27
+ if (thinkingLine) return `Latest thinking: ${thinkingLine}`;
28
+
29
+ const l1Line = firstNonEmptyLine(record.memory?.l1Summaries?.[0]?.summary);
30
+ if (l1Line) return `Prior context summary: ${l1Line}`;
31
+
32
+ return `Throughline handoff for ${record.stats?.preservedContextRows ?? 0} preserved memory rows.`;
33
+ }
34
+
35
+ function toDetailReference(ref) {
36
+ return {
37
+ type: 'throughline_detail',
38
+ label: `${ref.kind}:${ref.toolName}`,
39
+ command: ref.detailCommand,
40
+ sourceId: ref.sourceId,
41
+ detailKind: ref.kind,
42
+ originSessionId: ref.originSessionId,
43
+ turnNumber: ref.turnNumber,
44
+ };
45
+ }
46
+
47
+ function singleLine(text) {
48
+ return String(text ?? '').replace(/\n+/g, ' ').trim();
49
+ }
50
+
51
+ function assertDetailRefLimit(value) {
52
+ if (!Number.isInteger(value) || value < 0) {
53
+ throw new Error('maxDetailRefs must be a non-negative integer');
54
+ }
55
+ }
56
+
57
+ function assertRecentBodyLimit(value) {
58
+ if (!Number.isInteger(value) || value < 0) {
59
+ throw new Error('maxRecentBodies must be a non-negative integer');
60
+ }
61
+ }
62
+
63
+ function assertBodyMaxChars(value) {
64
+ if (!Number.isInteger(value) || value < 0) {
65
+ throw new Error('maxBodyChars must be a non-negative integer');
66
+ }
67
+ }
68
+
69
+ function truncateText(text, maxChars) {
70
+ const value = String(text ?? '');
71
+ if (maxChars === 0) return { text: '', truncated: value.length > 0 };
72
+ if (value.length <= maxChars) return { text: value, truncated: false };
73
+ return {
74
+ text: value.slice(0, maxChars).trimEnd(),
75
+ truncated: true,
76
+ };
77
+ }
78
+
79
+ function uniqueDetailRefsByCommand(refs) {
80
+ const seen = new Set();
81
+ const out = [];
82
+ for (const ref of refs) {
83
+ const key = ref.detailCommand;
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ out.push(ref);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ export function renderCodexActiveWorkContext(record) {
92
+ if (!record) {
93
+ throw new Error('renderCodexActiveWorkContext: record is required');
94
+ }
95
+
96
+ const lines = [];
97
+ lines.push('## Throughline: Active Work Context');
98
+ lines.push('');
99
+ lines.push('Intent: Continue the current Codex work thread using persisted Throughline memory.');
100
+ lines.push('');
101
+ lines.push('### Reading Contract');
102
+ lines.push(
103
+ 'This is current-task context for continuation, not a passive archive. ' +
104
+ 'Use it to infer the next action in the active work thread.',
105
+ );
106
+ lines.push(
107
+ 'Entries are oldest-to-newest. Later entries, in-flight memo, and latest thinking may supersede earlier hypotheses.',
108
+ );
109
+ lines.push(
110
+ 'Do not treat every older line as still-current truth. Prefer the latest actionable state.',
111
+ );
112
+ lines.push('');
113
+ lines.push('### Source');
114
+ lines.push(`Throughline session: ${record.session.id}`);
115
+ lines.push(`Project: ${record.session.projectPath ?? 'unknown'}`);
116
+ lines.push(`Source agent: ${record.source.adapter}`);
117
+
118
+ if (record.memory.inflightMemo) {
119
+ lines.push('');
120
+ lines.push('### In-flight Memo');
121
+ lines.push(record.memory.inflightMemo);
122
+ }
123
+
124
+ if (record.memory.latestThinking.length > 0) {
125
+ lines.push('');
126
+ lines.push('### Latest Thinking');
127
+ for (const row of record.memory.latestThinking) {
128
+ lines.push(`[${row.time}] ${row.text}`);
129
+ }
130
+ }
131
+
132
+ if (record.memory.l1Summaries.length > 0) {
133
+ lines.push('');
134
+ lines.push('### L1 Summaries');
135
+ for (const row of record.memory.l1Summaries) {
136
+ if (!row.summary || row.summary === '(no content)') continue;
137
+ lines.push(`[${row.time}] [${row.role}] ${row.summary.replace(/\n+/g, ' ').trim()}`);
138
+ }
139
+ }
140
+
141
+ if (record.memory.recentBodies.length > 0) {
142
+ lines.push('');
143
+ lines.push('### Active Work Thread (L2)');
144
+ lines.push('Entries are oldest-to-newest; later entries may supersede earlier hypotheses.');
145
+ for (const row of record.memory.recentBodies) {
146
+ if (!row.text) continue;
147
+ lines.push(`[${row.time}] [${row.role}] ${row.text}`);
148
+ }
149
+ }
150
+
151
+ if (record.references.l3.length > 0) {
152
+ lines.push('');
153
+ lines.push('### Detail References');
154
+ lines.push(
155
+ 'Use these only when L1/L2 are insufficient. Run the command locally; do not guess missing tool output.',
156
+ );
157
+ for (const ref of record.references.l3) {
158
+ lines.push(`- ${ref.kind}:${ref.toolName} turn ${ref.turnNumber}: ${ref.detailCommand}`);
159
+ }
160
+ }
161
+
162
+ lines.push('');
163
+ lines.push('### Continuation Instruction');
164
+ lines.push(
165
+ 'Continue from the latest actionable state represented above. Preserve user instructions and repository constraints. ' +
166
+ 'If details are missing, inspect local files or Throughline detail references before acting.',
167
+ );
168
+
169
+ return lines.join('\n');
170
+ }
171
+
172
+ export function renderCodexNewThreadHandoff(
173
+ record,
174
+ {
175
+ maxDetailRefs = DEFAULT_CODEX_HANDOFF_DETAIL_REF_LIMIT,
176
+ maxRecentBodies = DEFAULT_CODEX_HANDOFF_RECENT_BODY_LIMIT,
177
+ maxBodyChars = DEFAULT_CODEX_HANDOFF_BODY_MAX_CHARS,
178
+ } = {},
179
+ ) {
180
+ if (!record) {
181
+ throw new Error('renderCodexNewThreadHandoff: record is required');
182
+ }
183
+ assertDetailRefLimit(maxDetailRefs);
184
+ assertRecentBodyLimit(maxRecentBodies);
185
+ assertBodyMaxChars(maxBodyChars);
186
+
187
+ const lines = [];
188
+ lines.push('## Throughline: New Codex Thread Handoff');
189
+ lines.push('');
190
+ lines.push(
191
+ 'Purpose: Continue this work in a fresh Codex thread without mutating the risky current thread.',
192
+ );
193
+ lines.push(
194
+ 'Reading contract: This is current-task context, not a passive archive. Later entries, in-flight memo, and latest thinking supersede earlier notes.',
195
+ );
196
+ lines.push('');
197
+ lines.push('### Source');
198
+ lines.push(`Throughline session: ${record.session.id}`);
199
+ lines.push(`Project: ${record.session.projectPath ?? 'unknown'}`);
200
+ lines.push(`Source agent: ${record.source.adapter}`);
201
+ lines.push('');
202
+ lines.push('### Work Boundary');
203
+ lines.push(`Intent: ${record.intent}`);
204
+ if (record.constraints.length > 0) {
205
+ lines.push('Constraints:');
206
+ for (const constraint of record.constraints) {
207
+ lines.push(`- ${singleLine(constraint)}`);
208
+ }
209
+ }
210
+
211
+ if (record.memory.inflightMemo) {
212
+ lines.push('');
213
+ lines.push('### In-flight Memo');
214
+ lines.push(record.memory.inflightMemo);
215
+ }
216
+
217
+ if (record.memory.latestThinking.length > 0) {
218
+ lines.push('');
219
+ lines.push('### Latest Thinking');
220
+ for (const row of record.memory.latestThinking) {
221
+ lines.push(`[${row.time}] ${row.text}`);
222
+ }
223
+ }
224
+
225
+ if (record.memory.l1Summaries.length > 0) {
226
+ lines.push('');
227
+ lines.push('### L1 Memory Summaries');
228
+ lines.push('Oldest-to-newest; use later entries when summaries disagree.');
229
+ for (const row of record.memory.l1Summaries) {
230
+ if (!row.summary || row.summary === '(no content)') continue;
231
+ lines.push(`[${row.time}] [${row.role}] ${singleLine(row.summary)}`);
232
+ }
233
+ }
234
+
235
+ if (record.memory.recentBodies.length > 0) {
236
+ const bodies = record.memory.recentBodies;
237
+ const shownBodies = maxRecentBodies === 0 ? [] : bodies.slice(-maxRecentBodies);
238
+ const omittedBodies = bodies.length - shownBodies.length;
239
+ lines.push('');
240
+ lines.push('### Recent Active Thread (L2)');
241
+ lines.push('Oldest-to-newest; this is the active continuation surface.');
242
+ lines.push(
243
+ `Long entries are truncated for handoff; full context: throughline codex-resume --session ${record.session.id}`,
244
+ );
245
+ if (shownBodies.length === 0) {
246
+ lines.push(`${bodies.length} active L2 entries available; omitted from this fresh-thread handoff.`);
247
+ } else if (omittedBodies > 0) {
248
+ lines.push(`Showing latest ${shownBodies.length} of ${bodies.length} active L2 entries; ${omittedBodies} older omitted.`);
249
+ }
250
+ for (const row of shownBodies) {
251
+ if (!row.text) continue;
252
+ const body = truncateText(row.text, maxBodyChars);
253
+ lines.push(`[${row.time}] [${row.role}] ${body.text}`);
254
+ if (body.truncated) {
255
+ lines.push(`[entry truncated to ${maxBodyChars} chars]`);
256
+ }
257
+ }
258
+ }
259
+
260
+ if (record.references.l3.length > 0) {
261
+ const refs = uniqueDetailRefsByCommand(record.references.l3);
262
+ const shown = maxDetailRefs === 0 ? [] : refs.slice(-maxDetailRefs);
263
+ const omitted = refs.length - shown.length;
264
+ lines.push('');
265
+ lines.push('### Detail References');
266
+ lines.push('L3 bodies are not pasted here. Use local detail commands only when L1/L2 are insufficient.');
267
+ if (shown.length === 0) {
268
+ lines.push(`${refs.length} detail commands available; omitted from this fresh-thread handoff.`);
269
+ } else {
270
+ if (omitted > 0) {
271
+ lines.push(`Showing latest ${shown.length} of ${refs.length} detail commands; ${omitted} older omitted.`);
272
+ }
273
+ for (const ref of shown) {
274
+ lines.push(`- ${ref.kind}:${ref.toolName} turn ${ref.turnNumber}: ${ref.detailCommand}`);
275
+ }
276
+ }
277
+ }
278
+
279
+ lines.push('');
280
+ lines.push('### Start Instruction');
281
+ lines.push(
282
+ 'Continue from the latest actionable state above. Preserve user instructions and repository constraints. ' +
283
+ 'Do not mutate the original Codex thread; inspect local files or detail references before acting when context is missing.',
284
+ );
285
+
286
+ return lines.join('\n');
287
+ }
288
+
289
+ export function toCodexDeveloperMessageItem(record) {
290
+ return {
291
+ type: 'message',
292
+ role: 'developer',
293
+ content: [
294
+ {
295
+ type: 'input_text',
296
+ text: renderCodexActiveWorkContext(record),
297
+ },
298
+ ],
299
+ };
300
+ }
301
+
302
+ /**
303
+ * @param {ReturnType<import('./handoff-record.mjs').buildHandoffRecord>} record
304
+ * @param {{ hostMode?: 'claude-primary' | 'codex-primary' | 'unknown' }} [options]
305
+ */
306
+ export function toThroughlineHandoffBlock(record, { hostMode = 'claude-primary' } = {}) {
307
+ if (!record) {
308
+ throw new Error('toThroughlineHandoffBlock: record is required');
309
+ }
310
+
311
+ return {
312
+ kind: 'throughline_handoff',
313
+ source: 'throughline',
314
+ trust: 'local',
315
+ summary: summarizeRecord(record),
316
+ data: {
317
+ throughlineHandoffSchemaVersion: THROUGHLINE_HANDOFF_SCHEMA_VERSION,
318
+ handoffRecordVersion: record.version,
319
+ sessionId: record.session.id,
320
+ projectPath: record.session.projectPath,
321
+ sourceAgent: record.source.adapter,
322
+ hostMode,
323
+ intent: record.intent,
324
+ constraints: record.constraints,
325
+ originSessionIds: record.source.originSessionIds,
326
+ stats: record.stats,
327
+ memory: record.memory,
328
+ detailReferences: record.references.l3.map(toDetailReference),
329
+ },
330
+ };
331
+ }