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,246 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export const CODEX_SIDECAR_WORKFLOWS = Object.freeze([
4
+ 'review',
5
+ 'explore',
6
+ 'work',
7
+ 'opinion',
8
+ 'risk-check',
9
+ ]);
10
+
11
+ export const CODEX_SIDECAR_STATUS = Object.freeze({
12
+ DISABLED: 'disabled',
13
+ UNAVAILABLE: 'unavailable',
14
+ CONFIGURED: 'configured',
15
+ OPERATIONAL: 'operational',
16
+ WORK_CAPABLE: 'work-capable',
17
+ });
18
+
19
+ const DEFAULT_TIMEOUT_MS = 30_000;
20
+
21
+ function resolveCommand({ command, env }) {
22
+ return command ?? env.THROUGHLINE_CODEX_SIDECAR_BIN ?? 'codex-sidecar';
23
+ }
24
+
25
+ export function shouldShellWrapSidecarCommand(platform = process.platform) {
26
+ return platform === 'win32';
27
+ }
28
+
29
+ export function runCodexSidecarCommand(command, args, options = {}) {
30
+ return spawnSync(command, args, {
31
+ ...options,
32
+ shell: shouldShellWrapSidecarCommand(),
33
+ });
34
+ }
35
+
36
+ export function inferWorkflowForPreset(preset) {
37
+ if (CODEX_SIDECAR_WORKFLOWS.includes(preset)) return preset;
38
+ if (preset === 'summarize-l1') return 'explore';
39
+ return 'review';
40
+ }
41
+
42
+ /**
43
+ * Run codex-sidecar diagnostics without treating command presence as success.
44
+ *
45
+ * configured means diagnostics exited 0 for this repository. Any spawn failure
46
+ * or non-zero diagnostics result is explicit unavailable, not a hidden fallback.
47
+ *
48
+ * @param {{
49
+ * projectPath: string,
50
+ * preset?: string,
51
+ * command?: string,
52
+ * env?: NodeJS.ProcessEnv,
53
+ * timeoutMs?: number,
54
+ * }} params
55
+ */
56
+ export function diagnoseCodexSidecar({
57
+ projectPath,
58
+ preset = 'review',
59
+ command = null,
60
+ env = process.env,
61
+ timeoutMs = DEFAULT_TIMEOUT_MS,
62
+ }) {
63
+ if (!projectPath) {
64
+ throw new Error('diagnoseCodexSidecar: projectPath is required');
65
+ }
66
+
67
+ if (env.THROUGHLINE_CODEX_SIDECAR_DISABLED === '1') {
68
+ return {
69
+ status: CODEX_SIDECAR_STATUS.DISABLED,
70
+ reason: 'disabled_by_env',
71
+ command: null,
72
+ projectPath,
73
+ preset,
74
+ };
75
+ }
76
+
77
+ const resolvedCommand = resolveCommand({ command, env });
78
+ const args = ['diagnostics', '--project', projectPath, '--preset', preset];
79
+ const result = runCodexSidecarCommand(resolvedCommand, args, {
80
+ encoding: 'utf8',
81
+ env,
82
+ timeout: timeoutMs,
83
+ });
84
+
85
+ if (result.error) {
86
+ return {
87
+ status: CODEX_SIDECAR_STATUS.UNAVAILABLE,
88
+ reason: result.error.code === 'ENOENT' ? 'command_not_found' : 'spawn_failed',
89
+ command: resolvedCommand,
90
+ args,
91
+ projectPath,
92
+ preset,
93
+ error: result.error.message,
94
+ stdout: result.stdout ?? '',
95
+ stderr: result.stderr ?? '',
96
+ };
97
+ }
98
+
99
+ if (result.status !== 0) {
100
+ return {
101
+ status: CODEX_SIDECAR_STATUS.UNAVAILABLE,
102
+ reason: 'diagnostics_failed',
103
+ command: resolvedCommand,
104
+ args,
105
+ projectPath,
106
+ preset,
107
+ exitCode: result.status,
108
+ signal: result.signal ?? null,
109
+ stdout: result.stdout ?? '',
110
+ stderr: result.stderr ?? '',
111
+ };
112
+ }
113
+
114
+ return {
115
+ status: CODEX_SIDECAR_STATUS.CONFIGURED,
116
+ reason: 'diagnostics_passed',
117
+ command: resolvedCommand,
118
+ args,
119
+ projectPath,
120
+ preset,
121
+ exitCode: 0,
122
+ stdout: result.stdout ?? '',
123
+ stderr: result.stderr ?? '',
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Build and execute a codex-sidecar dry-run request.
129
+ *
130
+ * This intentionally does not treat command presence as success. Callers get a
131
+ * structured failed result on spawn / parse / non-zero cases.
132
+ *
133
+ * @param {{
134
+ * projectPath: string,
135
+ * workflow?: string,
136
+ * preset?: string,
137
+ * contextFile?: string | null,
138
+ * prompt?: string | null,
139
+ * turnTimeoutMs?: number | null,
140
+ * command?: string | null,
141
+ * env?: NodeJS.ProcessEnv,
142
+ * timeoutMs?: number,
143
+ * }} params
144
+ */
145
+ export function runCodexSidecarDryRun({
146
+ projectPath,
147
+ workflow = null,
148
+ preset = 'review',
149
+ contextFile = null,
150
+ prompt = null,
151
+ turnTimeoutMs = null,
152
+ command = null,
153
+ env = process.env,
154
+ timeoutMs = DEFAULT_TIMEOUT_MS,
155
+ }) {
156
+ if (!projectPath) {
157
+ throw new Error('runCodexSidecarDryRun: projectPath is required');
158
+ }
159
+
160
+ const resolvedWorkflow = workflow ?? inferWorkflowForPreset(preset);
161
+ if (!CODEX_SIDECAR_WORKFLOWS.includes(resolvedWorkflow)) {
162
+ throw new Error(
163
+ `runCodexSidecarDryRun: workflow must be one of ${CODEX_SIDECAR_WORKFLOWS.join(', ')}`,
164
+ );
165
+ }
166
+
167
+ const resolvedCommand = resolveCommand({ command, env });
168
+ const args = [
169
+ resolvedWorkflow,
170
+ '--project',
171
+ projectPath,
172
+ '--preset',
173
+ preset,
174
+ '--dry-run',
175
+ ];
176
+ if (contextFile) {
177
+ args.push('--context-file', contextFile);
178
+ }
179
+ if (turnTimeoutMs !== null) {
180
+ if (!Number.isInteger(turnTimeoutMs) || turnTimeoutMs < 1) {
181
+ throw new Error('runCodexSidecarDryRun: turnTimeoutMs must be a positive integer');
182
+ }
183
+ args.push('--turn-timeout-ms', String(turnTimeoutMs));
184
+ }
185
+ if (prompt) {
186
+ args.push(prompt);
187
+ }
188
+
189
+ const result = runCodexSidecarCommand(resolvedCommand, args, {
190
+ encoding: 'utf8',
191
+ env,
192
+ timeout: timeoutMs,
193
+ });
194
+
195
+ if (result.error) {
196
+ return {
197
+ status: 'failed',
198
+ reason: result.error.code === 'ENOENT' ? 'command_not_found' : 'spawn_failed',
199
+ command: resolvedCommand,
200
+ args,
201
+ projectPath,
202
+ workflow: resolvedWorkflow,
203
+ preset,
204
+ error: result.error.message,
205
+ stdout: result.stdout ?? '',
206
+ stderr: result.stderr ?? '',
207
+ };
208
+ }
209
+
210
+ if (result.status !== 0) {
211
+ return {
212
+ status: 'failed',
213
+ reason: 'dry_run_failed',
214
+ command: resolvedCommand,
215
+ args,
216
+ projectPath,
217
+ workflow: resolvedWorkflow,
218
+ preset,
219
+ exitCode: result.status,
220
+ signal: result.signal ?? null,
221
+ stdout: result.stdout ?? '',
222
+ stderr: result.stderr ?? '',
223
+ };
224
+ }
225
+
226
+ try {
227
+ return {
228
+ ...JSON.parse(result.stdout),
229
+ command: resolvedCommand,
230
+ args,
231
+ };
232
+ } catch (err) {
233
+ return {
234
+ status: 'failed',
235
+ reason: 'invalid_json',
236
+ command: resolvedCommand,
237
+ args,
238
+ projectPath,
239
+ workflow: resolvedWorkflow,
240
+ preset,
241
+ error: err instanceof Error ? err.message : String(err),
242
+ stdout: result.stdout ?? '',
243
+ stderr: result.stderr ?? '',
244
+ };
245
+ }
246
+ }
@@ -0,0 +1,172 @@
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 {
7
+ CODEX_SIDECAR_STATUS,
8
+ diagnoseCodexSidecar,
9
+ inferWorkflowForPreset,
10
+ runCodexSidecarDryRun,
11
+ shouldShellWrapSidecarCommand,
12
+ } from './codex-sidecar.mjs';
13
+
14
+ function makeExecutable(dir, name, body) {
15
+ const path = join(dir, name);
16
+ writeFileSync(path, body);
17
+ chmodSync(path, 0o755);
18
+ return path;
19
+ }
20
+
21
+ test('diagnoseCodexSidecar: disabled env is explicit disabled status', () => {
22
+ const result = diagnoseCodexSidecar({
23
+ projectPath: '/repo',
24
+ env: { ...process.env, THROUGHLINE_CODEX_SIDECAR_DISABLED: '1' },
25
+ });
26
+
27
+ assert.equal(result.status, CODEX_SIDECAR_STATUS.DISABLED);
28
+ assert.equal(result.reason, 'disabled_by_env');
29
+ });
30
+
31
+ test('diagnoseCodexSidecar: missing command is unavailable, not configured', () => {
32
+ const result = diagnoseCodexSidecar({
33
+ projectPath: '/repo',
34
+ command: '/definitely/missing/codex-sidecar',
35
+ });
36
+
37
+ assert.equal(result.status, CODEX_SIDECAR_STATUS.UNAVAILABLE);
38
+ assert.equal(result.reason, 'command_not_found');
39
+ });
40
+
41
+ test('diagnoseCodexSidecar: non-zero diagnostics is unavailable', () => {
42
+ const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-fail-'));
43
+ try {
44
+ const bin = makeExecutable(
45
+ dir,
46
+ 'fake-sidecar',
47
+ '#!/usr/bin/env bash\nprintf "bad config" >&2\nexit 7\n',
48
+ );
49
+ const result = diagnoseCodexSidecar({
50
+ projectPath: '/repo',
51
+ command: bin,
52
+ });
53
+
54
+ assert.equal(result.status, CODEX_SIDECAR_STATUS.UNAVAILABLE);
55
+ assert.equal(result.reason, 'diagnostics_failed');
56
+ assert.equal(result.exitCode, 7);
57
+ assert.match(result.stderr, /bad config/);
58
+ } finally {
59
+ rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('diagnoseCodexSidecar: zero diagnostics is configured', () => {
64
+ const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-ok-'));
65
+ try {
66
+ const bin = makeExecutable(
67
+ dir,
68
+ 'fake-sidecar',
69
+ '#!/usr/bin/env bash\nprintf "ok diagnostics for $*\\n"\nexit 0\n',
70
+ );
71
+ const result = diagnoseCodexSidecar({
72
+ projectPath: '/repo',
73
+ preset: 'review',
74
+ command: bin,
75
+ });
76
+
77
+ assert.equal(result.status, CODEX_SIDECAR_STATUS.CONFIGURED);
78
+ assert.equal(result.reason, 'diagnostics_passed');
79
+ assert.match(result.stdout, /diagnostics --project \/repo --preset review/);
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+
85
+ test('inferWorkflowForPreset: maps known presets without guessing unavailable workflows', () => {
86
+ assert.equal(inferWorkflowForPreset('review'), 'review');
87
+ assert.equal(inferWorkflowForPreset('risk-check'), 'risk-check');
88
+ assert.equal(inferWorkflowForPreset('summarize-l1'), 'explore');
89
+ assert.equal(inferWorkflowForPreset('custom-review-preset'), 'review');
90
+ });
91
+
92
+ test('shouldShellWrapSidecarCommand: wraps npm bin shims on Windows only', () => {
93
+ assert.equal(shouldShellWrapSidecarCommand('win32'), true);
94
+ assert.equal(shouldShellWrapSidecarCommand('linux'), false);
95
+ assert.equal(shouldShellWrapSidecarCommand('darwin'), false);
96
+ });
97
+
98
+ test('runCodexSidecarDryRun: emits a dry-run request for review preset', () => {
99
+ const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-dry-run-'));
100
+ try {
101
+ const argsFile = join(dir, 'args.txt');
102
+ const bin = makeExecutable(
103
+ dir,
104
+ 'fake-sidecar',
105
+ `#!/usr/bin/env bash
106
+ printf '%s\\n' "$@" > "${argsFile}"
107
+ printf '{"status":"dry-run","workflow":"review","normalizedRequest":{"dryRun":true}}\\n'
108
+ `,
109
+ );
110
+ const result = runCodexSidecarDryRun({
111
+ projectPath: '/repo',
112
+ preset: 'review',
113
+ command: bin,
114
+ prompt: 'review prompt',
115
+ turnTimeoutMs: 12345,
116
+ });
117
+
118
+ assert.equal(result.status, 'dry-run');
119
+ assert.equal(result.workflow, 'review');
120
+ const argv = readFileSync(argsFile, 'utf8').trim().split('\n');
121
+ assert.deepEqual(argv, [
122
+ 'review',
123
+ '--project',
124
+ '/repo',
125
+ '--preset',
126
+ 'review',
127
+ '--dry-run',
128
+ '--turn-timeout-ms',
129
+ '12345',
130
+ 'review prompt',
131
+ ]);
132
+ } finally {
133
+ rmSync(dir, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ test('runCodexSidecarDryRun: infers risk-check workflow from preset', () => {
138
+ const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-risk-dry-run-'));
139
+ try {
140
+ const argsFile = join(dir, 'args.txt');
141
+ const bin = makeExecutable(
142
+ dir,
143
+ 'fake-sidecar',
144
+ `#!/usr/bin/env bash
145
+ printf '%s\\n' "$@" > "${argsFile}"
146
+ printf '{"status":"dry-run","workflow":"risk-check","normalizedRequest":{"dryRun":true}}\\n'
147
+ `,
148
+ );
149
+ const result = runCodexSidecarDryRun({
150
+ projectPath: '/repo',
151
+ preset: 'risk-check',
152
+ command: bin,
153
+ contextFile: '/tmp/context.json',
154
+ });
155
+
156
+ assert.equal(result.status, 'dry-run');
157
+ assert.equal(result.workflow, 'risk-check');
158
+ const argv = readFileSync(argsFile, 'utf8').trim().split('\n');
159
+ assert.deepEqual(argv, [
160
+ 'risk-check',
161
+ '--project',
162
+ '/repo',
163
+ '--preset',
164
+ 'risk-check',
165
+ '--dry-run',
166
+ '--context-file',
167
+ '/tmp/context.json',
168
+ ]);
169
+ } finally {
170
+ rmSync(dir, { recursive: true, force: true });
171
+ }
172
+ });
@@ -0,0 +1,143 @@
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(fileURLToPath(import.meta.url)));
10
+
11
+ function makeTempHome() {
12
+ return mkdtempSync(join(tmpdir(), 'tl-codex-summarize-home-'));
13
+ }
14
+
15
+ function makeTempProject() {
16
+ return mkdtempSync(join(tmpdir(), 'tl-codex-summarize-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
+ let stdin = '';
25
+ process.stdin.setEncoding('utf8');
26
+ process.stdin.on('data', (chunk) => { stdin += chunk; });
27
+ process.stdin.on('end', () => {
28
+ if (process.argv[2] !== 'exec') process.exit(7);
29
+ if (!stdin.includes('[assistant]: assistant turn 1')) process.exit(8);
30
+ process.stdout.write('fake codex l1 summary\\n');
31
+ });
32
+ `,
33
+ );
34
+ chmodSync(script, 0o755);
35
+ return script;
36
+ }
37
+
38
+ async function seedDb(home, project, turnCount = 21) {
39
+ const originalHome = process.env.HOME;
40
+ const originalUserProfile = process.env.USERPROFILE;
41
+ process.env.HOME = home;
42
+ process.env.USERPROFILE = home;
43
+ try {
44
+ const mod = await import(`./db.mjs?codexSummarize=${Date.now()}-${Math.random()}`);
45
+ const db = mod.getDb();
46
+ db.prepare(
47
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
48
+ VALUES ('codex:thread-summary', ?, 'active', 1, 2)`,
49
+ ).run(project);
50
+ const insert = db.prepare(
51
+ `INSERT INTO bodies
52
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
53
+ VALUES ('codex:thread-summary', 'codex:thread-summary', ?, ?, ?, 3, ?)`,
54
+ );
55
+ for (let turn = 1; turn <= turnCount; turn++) {
56
+ insert.run(turn, 'user', `user turn ${turn}`, turn * 100);
57
+ insert.run(turn, 'assistant', `assistant turn ${turn}`, turn * 100 + 1);
58
+ }
59
+ db.close();
60
+ } finally {
61
+ if (originalHome === undefined) delete process.env.HOME;
62
+ else process.env.HOME = originalHome;
63
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
64
+ else process.env.USERPROFILE = originalUserProfile;
65
+ }
66
+ }
67
+
68
+ function runSummarize(home, project, args = [], extraEnv = {}) {
69
+ return spawnSync(
70
+ process.execPath,
71
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'codex-summarize', ...args],
72
+ {
73
+ cwd: project,
74
+ env: {
75
+ ...process.env,
76
+ HOME: home,
77
+ USERPROFILE: home,
78
+ ...extraEnv,
79
+ },
80
+ encoding: 'utf8',
81
+ },
82
+ );
83
+ }
84
+
85
+ test('codex-summarize writes L1 skeleton through Codex CLI backend', async () => {
86
+ const home = makeTempHome();
87
+ const project = makeTempProject();
88
+ try {
89
+ await seedDb(home, project, 21);
90
+ const fake = makeFakeCodexCli(project);
91
+ const result = runSummarize(
92
+ home,
93
+ project,
94
+ ['--session', 'codex:thread-summary', '--json'],
95
+ { THROUGHLINE_CODEX_CLI_BIN: fake },
96
+ );
97
+
98
+ assert.equal(result.status, 0, result.stderr);
99
+ const payload = JSON.parse(result.stdout);
100
+ assert.equal(payload.status, 'summarized');
101
+ assert.equal(payload.reason, 'codex_cli_l1_written');
102
+ assert.equal(payload.summarized[0].turnNumber, 1);
103
+ assert.equal(payload.summarized[0].source, 'codex-cli');
104
+
105
+ const originalHome = process.env.HOME;
106
+ const originalUserProfile = process.env.USERPROFILE;
107
+ process.env.HOME = home;
108
+ process.env.USERPROFILE = home;
109
+ try {
110
+ const mod = await import(`./db.mjs?codexSummarizeAssert=${Date.now()}-${Math.random()}`);
111
+ const db = mod.getDb();
112
+ const row = db.prepare('SELECT summary FROM skeletons WHERE turn_number = 1').get();
113
+ assert.equal(row.summary, 'fake codex l1 summary');
114
+ db.close();
115
+ } finally {
116
+ if (originalHome === undefined) delete process.env.HOME;
117
+ else process.env.HOME = originalHome;
118
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
119
+ else process.env.USERPROFILE = originalUserProfile;
120
+ }
121
+ } finally {
122
+ rmSync(project, { recursive: true, force: true });
123
+ rmSync(home, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('codex-summarize skips sessions inside the L2 window', async () => {
128
+ const home = makeTempHome();
129
+ const project = makeTempProject();
130
+ try {
131
+ await seedDb(home, project, 20);
132
+ const result = runSummarize(home, project, ['--session', 'codex:thread-summary', '--json']);
133
+
134
+ assert.equal(result.status, 0, result.stderr);
135
+ const payload = JSON.parse(result.stdout);
136
+ assert.equal(payload.status, 'skipped');
137
+ assert.equal(payload.reason, 'within_l2_window');
138
+ assert.deepEqual(payload.summarized, []);
139
+ } finally {
140
+ rmSync(project, { recursive: true, force: true });
141
+ rmSync(home, { recursive: true, force: true });
142
+ }
143
+ });
@@ -0,0 +1,23 @@
1
+ export function resolveCodexThreadIdentity({ codexThreadId = null } = {}, env = process.env) {
2
+ if (codexThreadId) {
3
+ return {
4
+ codexThreadId,
5
+ codexThreadIdSource: 'arg:--codex-thread-id',
6
+ };
7
+ }
8
+
9
+ for (const name of ['THROUGHLINE_CODEX_THREAD_ID', 'CODEX_THREAD_ID']) {
10
+ const value = typeof env[name] === 'string' ? env[name].trim() : '';
11
+ if (value) {
12
+ return {
13
+ codexThreadId: value,
14
+ codexThreadIdSource: `env:${name}`,
15
+ };
16
+ }
17
+ }
18
+
19
+ return {
20
+ codexThreadId: null,
21
+ codexThreadIdSource: null,
22
+ };
23
+ }