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,623 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdirSync, 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
+ import { DatabaseSync } from 'node:sqlite';
9
+
10
+ import { runCodexRestoreSourceAudit } from './codex-restore-source-audit.mjs';
11
+
12
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
13
+
14
+ function makeTempDir(prefix) {
15
+ return mkdtempSync(join(tmpdir(), prefix));
16
+ }
17
+
18
+ function writeCodexRollout(codexHome, { project, threadId, turnCount = 2 }) {
19
+ const dir = join(codexHome, 'sessions', '2026', '05', '07');
20
+ mkdirSync(dir, { recursive: true });
21
+ const path = join(dir, `rollout-2026-05-07T00-00-00-${threadId}.jsonl`);
22
+ const rows = [
23
+ {
24
+ timestamp: '2026-05-07T00:00:00.000Z',
25
+ type: 'session_meta',
26
+ payload: {
27
+ id: threadId,
28
+ timestamp: '2026-05-07T00:00:00.000Z',
29
+ cwd: project,
30
+ source: 'vscode',
31
+ cli_version: '0.128.0-alpha.1',
32
+ },
33
+ },
34
+ ];
35
+
36
+ for (let turn = 1; turn <= turnCount; turn++) {
37
+ rows.push({
38
+ timestamp: `2026-05-07T00:00:0${turn}.000Z`,
39
+ type: 'event_msg',
40
+ payload: { type: 'user_message', message: `restore audit user ${turn}` },
41
+ });
42
+ rows.push({
43
+ timestamp: `2026-05-07T00:00:0${turn}.100Z`,
44
+ type: 'event_msg',
45
+ payload: { type: 'task_started' },
46
+ });
47
+ rows.push({
48
+ timestamp: `2026-05-07T00:00:0${turn}.200Z`,
49
+ type: 'event_msg',
50
+ payload: { type: 'agent_message', message: `restore audit assistant ${turn}` },
51
+ });
52
+ rows.push({
53
+ timestamp: `2026-05-07T00:00:0${turn}.300Z`,
54
+ type: 'event_msg',
55
+ payload: { type: 'task_complete' },
56
+ });
57
+ }
58
+
59
+ writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
60
+ return path;
61
+ }
62
+
63
+ function writeSessionIndex(codexHome, threadId) {
64
+ writeFileSync(
65
+ join(codexHome, 'session_index.jsonl'),
66
+ JSON.stringify({
67
+ id: threadId,
68
+ thread_name: 'restore source audit thread',
69
+ updated_at: '2026-05-07T00:00:10Z',
70
+ }) + '\n',
71
+ );
72
+ }
73
+
74
+ function writeStateDatabase(codexHome, { threadId, rolloutPath, project }) {
75
+ const db = new DatabaseSync(join(codexHome, 'state_5.sqlite'));
76
+ db.exec(`
77
+ create table threads (
78
+ id text primary key,
79
+ rollout_path text,
80
+ source text,
81
+ cwd text,
82
+ title text,
83
+ updated_at text
84
+ );
85
+ `);
86
+ db.prepare(
87
+ 'insert into threads (id, rollout_path, source, cwd, title, updated_at) values (?, ?, ?, ?, ?, ?)',
88
+ ).run(threadId, rolloutPath, 'vscode', project, 'restore source audit thread', '2026-05-07T00:00:10Z');
89
+ db.close();
90
+ }
91
+
92
+ test('runCodexRestoreSourceAudit inventories rollout, session index, state DB, and VS Code storage', () => {
93
+ const codexHome = makeTempDir('tl-audit-codex-');
94
+ const project = makeTempDir('tl-audit-project-');
95
+ const storage = makeTempDir('tl-audit-storage-');
96
+ const extensionRoot = makeTempDir('tl-audit-extension-');
97
+ const settingsRoot = makeTempDir('tl-audit-settings-');
98
+ const logRoot = makeTempDir('tl-audit-logs-');
99
+ const threadId = '019dfdef-0000-7000-8000-000000000101';
100
+ try {
101
+ const rolloutPath = writeCodexRollout(codexHome, { project, threadId });
102
+ writeSessionIndex(codexHome, threadId);
103
+ writeStateDatabase(codexHome, { threadId, rolloutPath, project });
104
+ writeFileSync(join(storage, 'state.vscdb'), `cached thread ${threadId}`);
105
+ writeFileSync(
106
+ join(extensionRoot, 'extension.js'),
107
+ [
108
+ 'client.request("thread/read")',
109
+ 'client.request("thread/resume", { threadId, history:null, path: state.rolloutPath ?? null })',
110
+ 'commands.registerCommand("mark-all-conversations-need-resume-after-reconnect-for-host", () => markAllConversationsNeedResumeAfterReconnect())',
111
+ 'markAllConversationsNeedResumeAfterReconnect()',
112
+ 'window.localStorage.setItem("codex:persisted-atom:x", "1")',
113
+ '"chatgpt.followUpQueueMode"',
114
+ '"send-follow-up-message"',
115
+ '{ type:"steeringUserMessage", restoreMessage: "previous user text" }',
116
+ 'const projected = replacement_history.filter((item) => !item.tombstone)',
117
+ 'function broadcastIpcStatePatches(){ dispatchMessageFromView("thread-stream-state-changed",{change:{type:"patches",patches:t}}) }',
118
+ 'function handleThreadStreamStateChanged(){ try { sn(n,t.patches) } catch (e) { warning("Failed to apply patches for") } }',
119
+ ].join('\n'),
120
+ );
121
+ writeFileSync(
122
+ join(extensionRoot, 'package.json'),
123
+ JSON.stringify({
124
+ contributes: {
125
+ configuration: {
126
+ properties: {
127
+ 'chatgpt.followUpQueueMode': { default: 'queue' },
128
+ },
129
+ },
130
+ },
131
+ }),
132
+ );
133
+ writeFileSync(
134
+ join(settingsRoot, 'settings.json'),
135
+ JSON.stringify({ 'chatgpt.followUpQueueMode': 'queue' }),
136
+ );
137
+ writeFileSync(join(logRoot, 'extension.log'), `restored thread ${threadId}`);
138
+
139
+ const result = runCodexRestoreSourceAudit({
140
+ threadId,
141
+ codexHome,
142
+ projectPath: project,
143
+ vscodeStorageRoots: [storage],
144
+ vscodeExtensionRoots: [extensionRoot],
145
+ vscodeSettingsRoots: [settingsRoot],
146
+ vscodeLogRoots: [logRoot],
147
+ });
148
+
149
+ assert.equal(result.status, 'restore-source-audit-complete');
150
+ assert.equal(result.restartSafe, false);
151
+ assert.equal(result.proofScope, 'local_restore_source_inventory_only');
152
+ assert.equal(result.rollout.capturedTurns, 2);
153
+ assert.equal(result.sessionIndex.containsThreadId, true);
154
+ assert.equal(result.stateDatabases.threadMatches, 1);
155
+ assert.equal(result.stateDatabases.conclusion, 'state_database_appears_metadata_only');
156
+ assert.equal(result.vscodeStorage.matches.length, 1);
157
+ assert.deepEqual(result.vscodeStorage.matches[0].needles, ['thread_id']);
158
+ assert.equal(result.vscodeExtension.status, 'searched');
159
+ assert.equal(result.vscodeExtension.evidence.thread_read, true);
160
+ assert.equal(result.vscodeExtension.evidence.thread_resume, true);
161
+ assert.equal(result.vscodeExtension.evidence.mark_need_resume_after_reconnect, true);
162
+ assert.equal(result.vscodeExtension.evidence.persisted_atom, true);
163
+ assert.equal(result.vscodeExtension.evidence.follow_up_queue_setting, true);
164
+ assert.equal(result.vscodeExtension.evidence.send_follow_up_message, true);
165
+ assert.equal(result.vscodeExtension.evidence.steering_user_message, true);
166
+ assert.equal(
167
+ result.vscodeExtension.sourceFacts.evidence.thread_resume_uses_null_history,
168
+ true,
169
+ );
170
+ assert.equal(
171
+ result.vscodeExtension.sourceFacts.evidence.thread_resume_uses_rollout_path,
172
+ true,
173
+ );
174
+ assert.equal(
175
+ result.vscodeExtension.sourceFacts.evidence.reconnect_command_marks_threads_need_resume,
176
+ true,
177
+ );
178
+ assert.equal(
179
+ result.vscodeExtension.sourceFacts.evidence.steering_user_message_has_restore_message,
180
+ true,
181
+ );
182
+ assert.equal(
183
+ result.vscodeExtension.sourceFacts.evidence.owner_broadcasts_thread_state_patches,
184
+ true,
185
+ );
186
+ assert.equal(
187
+ result.vscodeExtension.sourceFacts.evidence.follower_applies_thread_state_patches,
188
+ true,
189
+ );
190
+ assert.equal(
191
+ result.vscodeExtension.sourceFacts.evidence.patch_apply_failure_logged_in_thread_stream_handler,
192
+ true,
193
+ );
194
+ assert.equal(result.vscodeExtension.sourceFacts.reconnectResumeViaAppServerRolloutPath, true);
195
+ assert.equal(result.vscodeExtension.sourceFacts.threadStreamPatchApplyPathPresent, true);
196
+ assert.equal(
197
+ result.vscodeExtension.sourceFacts.rollbackNonResurrectionProjectionPathPresent,
198
+ true,
199
+ );
200
+ assert.deepEqual(result.vscodeExtension.sourceFacts.rollbackNonResurrectionProjectionCandidates, [
201
+ 'replacement_history_filter_candidate',
202
+ 'replacement_history_tombstone_candidate',
203
+ ]);
204
+ assert.equal(
205
+ result.vscodeExtension.sourceFacts.hypothesis,
206
+ 'reconnect_marks_threads_needing_app_server_resume_from_rollout_path',
207
+ );
208
+ const extensionMatch = result.vscodeExtension.matches.find((match) =>
209
+ match.patterns.includes('thread_read'),
210
+ );
211
+ assert.ok(extensionMatch);
212
+ assert.ok(extensionMatch.sourceSnippets.length > 0);
213
+ assert.ok(extensionMatch.sourceSnippets.some((snippet) => snippet.pattern === 'thread_read'));
214
+ assert.match(
215
+ extensionMatch.sourceSnippets.find((snippet) => snippet.pattern === 'thread_read')?.excerpt ?? '',
216
+ /thread\/read/,
217
+ );
218
+ assert.equal(result.vscodeExtension.restorePathSignals.hasWebviewPersistenceSignals, true);
219
+ assert.equal(result.vscodeExtension.restorePathSignals.hasFollowUpQueueSignals, true);
220
+ assert.equal(result.vscodeExtension.packageSettings.followUpQueueModeDefault.status, 'present');
221
+ assert.deepEqual(result.vscodeExtension.packageSettings.followUpQueueModeDefault.values, ['queue']);
222
+ assert.equal(result.vscodeSettings.followUpQueueMode.status, 'explicit');
223
+ assert.deepEqual(result.vscodeSettings.followUpQueueMode.values, ['queue']);
224
+ assert.equal(result.vscodeSettings.matches.length, 1);
225
+ assert.equal(result.vscodeLogs.matches.length, 1);
226
+ assert.deepEqual(result.vscodeLogs.matches[0].needles, ['thread_id']);
227
+ assert.equal(
228
+ result.vscodeExtension.conclusion,
229
+ 'vscode_extension_reconnect_appears_to_resume_threads_via_app_server',
230
+ );
231
+ assert.equal(result.summary.vscodeExtensionMatches, 2);
232
+ assert.equal(result.summary.vscodeExtensionSourceFacts.reconnectResumeViaAppServerRolloutPath, true);
233
+ assert.equal(result.summary.vscodeThreadStreamPatchApplyPathPresent, true);
234
+ assert.equal(result.summary.vscodeThreadStreamPatchFailureSignal, false);
235
+ assert.equal(result.summary.vscodeRollbackNonResurrectionProjectionPathPresent, true);
236
+ assert.deepEqual(result.summary.vscodeRollbackNonResurrectionProjectionCandidates, [
237
+ 'replacement_history_filter_candidate',
238
+ 'replacement_history_tombstone_candidate',
239
+ ]);
240
+ assert.ok(result.summary.vscodeExtensionSourceSnippetCount > 0);
241
+ assert.deepEqual(result.summary.vscodeExtensionFollowUpQueueModeDefault.values, ['queue']);
242
+ assert.equal(result.summary.vscodeSettingsSearched, true);
243
+ assert.equal(result.summary.vscodeSettingsFollowUpQueueMode.status, 'explicit');
244
+ assert.equal(result.summary.vscodeLogSearched, true);
245
+ assert.equal(result.summary.vscodeLogMatches, 1);
246
+ assert.equal(result.summary.vscodeLogThreadIdMatches, 1);
247
+ assert.equal(result.summary.vscodeLogRetainedTextMatches, 0);
248
+ assert.equal(result.summary.vscodeLogPatchApplyFailures, 0);
249
+ assert.equal(result.summary.vscodeLogPatchApplyFailureFirstTimestamp, null);
250
+ assert.equal(result.summary.vscodeLogPatchApplyFailureLastTimestamp, null);
251
+ } finally {
252
+ rmSync(codexHome, { recursive: true, force: true });
253
+ rmSync(project, { recursive: true, force: true });
254
+ rmSync(storage, { recursive: true, force: true });
255
+ rmSync(extensionRoot, { recursive: true, force: true });
256
+ rmSync(settingsRoot, { recursive: true, force: true });
257
+ rmSync(logRoot, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ test('runCodexRestoreSourceAudit classifies VS Code log restore signals', () => {
262
+ const codexHome = makeTempDir('tl-audit-codex-');
263
+ const project = makeTempDir('tl-audit-project-');
264
+ const logRoot = makeTempDir('tl-audit-logs-');
265
+ const threadId = '019dfdef-0000-7000-8000-000000000108';
266
+ const retainedText = 'long retained rollback text visible in vscode log audit';
267
+ try {
268
+ writeRiskyRollbackRollout(codexHome, {
269
+ project,
270
+ threadId,
271
+ userMessages: [retainedText],
272
+ });
273
+ writeFileSync(
274
+ join(logRoot, 'Codex.log'),
275
+ [
276
+ `2026-05-07 00:35:35.339 [warning] Failed to apply patches for conversationId=${threadId} error={}`,
277
+ '2026-05-07 00:35:36.168 [warning] Received broadcast but no handler is configured method=thread-stream-state-changed',
278
+ `2026-05-07 00:35:37.000 [info] diagnostic retained text: ${retainedText}`,
279
+ '2026-05-07 00:35:38.000 [info] replacement_history appeared in a diagnostic log line',
280
+ `2026-05-07 00:35:39.101 [warning] Failed to apply patches for conversationId=${threadId} error={}`,
281
+ ].join('\n'),
282
+ );
283
+
284
+ const result = runCodexRestoreSourceAudit({
285
+ threadId,
286
+ codexHome,
287
+ projectPath: project,
288
+ vscodeStorageRoots: [],
289
+ vscodeExtensionRoots: [],
290
+ vscodeSettingsRoots: [],
291
+ vscodeLogRoots: [logRoot],
292
+ });
293
+
294
+ assert.equal(result.vscodeLogs.matches.length, 1);
295
+ assert.deepEqual(
296
+ [...new Set(result.vscodeLogs.matches[0].needles)].sort(),
297
+ ['retained_rollback_text_1', 'thread_id'],
298
+ );
299
+ assert.equal(result.vscodeLogs.signals.threadIdMatches, 2);
300
+ assert.equal(result.vscodeLogs.signals.retainedTextMatches, 1);
301
+ assert.equal(result.vscodeLogs.signals.patchApplyFailures, 2);
302
+ assert.equal(result.vscodeLogs.signals.threadStreamStateSignals, 1);
303
+ assert.equal(result.vscodeLogs.signals.replacementHistorySignals, 1);
304
+ const patchFailure = result.vscodeLogs.signalMatches.find(
305
+ (match) => match.signal === 'patch_apply_failure',
306
+ );
307
+ assert.equal(patchFailure?.firstTimestamp, '2026-05-07 00:35:35.339');
308
+ assert.equal(patchFailure?.lastTimestamp, '2026-05-07 00:35:39.101');
309
+ assert.deepEqual(
310
+ [...new Set(result.vscodeLogs.signalMatches.map((match) => match.signal))].sort(),
311
+ ['patch_apply_failure', 'replacement_history', 'thread_stream_state_broadcast'],
312
+ );
313
+ assert.equal(result.summary.vscodeLogThreadIdMatches, 2);
314
+ assert.equal(result.summary.vscodeLogRetainedTextMatches, 1);
315
+ assert.equal(result.summary.vscodeLogPatchApplyFailures, 2);
316
+ assert.equal(
317
+ result.summary.vscodeLogPatchApplyFailureFirstTimestamp,
318
+ '2026-05-07 00:35:35.339',
319
+ );
320
+ assert.equal(
321
+ result.summary.vscodeLogPatchApplyFailureLastTimestamp,
322
+ '2026-05-07 00:35:39.101',
323
+ );
324
+ assert.equal(result.summary.vscodeLogThreadStreamStateSignals, 1);
325
+ assert.equal(result.summary.vscodeLogReplacementHistorySignals, 1);
326
+ } finally {
327
+ rmSync(codexHome, { recursive: true, force: true });
328
+ rmSync(project, { recursive: true, force: true });
329
+ rmSync(logRoot, { recursive: true, force: true });
330
+ }
331
+ });
332
+
333
+ test('runCodexRestoreSourceAudit treats resume history alone as static evidence, not proof', () => {
334
+ const codexHome = makeTempDir('tl-audit-codex-');
335
+ const project = makeTempDir('tl-audit-project-');
336
+ const extensionRoot = makeTempDir('tl-audit-extension-');
337
+ const threadId = '019dfdef-0000-7000-8000-000000000105';
338
+ try {
339
+ writeCodexRollout(codexHome, { project, threadId, turnCount: 1 });
340
+ writeFileSync(join(extensionRoot, 'extension.js'), 'client.request("thread/resume")');
341
+
342
+ const result = runCodexRestoreSourceAudit({
343
+ threadId,
344
+ codexHome,
345
+ projectPath: project,
346
+ vscodeStorageRoots: [],
347
+ vscodeExtensionRoots: [extensionRoot],
348
+ vscodeSettingsRoots: [],
349
+ vscodeLogRoots: [],
350
+ });
351
+
352
+ assert.equal(result.restartSafe, false);
353
+ assert.equal(result.vscodeExtension.evidence.thread_resume, true);
354
+ assert.equal(
355
+ result.vscodeExtension.sourceFacts.rollbackNonResurrectionProjectionPathPresent,
356
+ false,
357
+ );
358
+ assert.equal(
359
+ result.vscodeExtension.conclusion,
360
+ 'vscode_extension_references_app_server_thread_restore_methods',
361
+ );
362
+ assert.equal(result.proofScope, 'local_restore_source_inventory_only');
363
+ } finally {
364
+ rmSync(codexHome, { recursive: true, force: true });
365
+ rmSync(project, { recursive: true, force: true });
366
+ rmSync(extensionRoot, { recursive: true, force: true });
367
+ }
368
+ });
369
+
370
+ test('runCodexRestoreSourceAudit can suppress VS Code extension source snippets', () => {
371
+ const codexHome = makeTempDir('tl-audit-codex-');
372
+ const project = makeTempDir('tl-audit-project-');
373
+ const extensionRoot = makeTempDir('tl-audit-extension-');
374
+ const threadId = '019dfdef-0000-7000-8000-000000000106';
375
+ try {
376
+ writeCodexRollout(codexHome, { project, threadId, turnCount: 1 });
377
+ writeFileSync(join(extensionRoot, 'extension.js'), 'client.request("thread/resume")');
378
+
379
+ const result = runCodexRestoreSourceAudit({
380
+ threadId,
381
+ codexHome,
382
+ projectPath: project,
383
+ vscodeStorageRoots: [],
384
+ vscodeExtensionRoots: [extensionRoot],
385
+ vscodeSettingsRoots: [],
386
+ vscodeLogRoots: [],
387
+ maxExtensionSourceSnippets: 0,
388
+ });
389
+
390
+ assert.equal(result.vscodeExtension.evidence.thread_resume, true);
391
+ assert.equal(result.vscodeExtension.matches.length, 1);
392
+ assert.deepEqual(result.vscodeExtension.matches[0].sourceSnippets, []);
393
+ assert.equal(result.summary.vscodeExtensionSourceSnippetCount, 0);
394
+ } finally {
395
+ rmSync(codexHome, { recursive: true, force: true });
396
+ rmSync(project, { recursive: true, force: true });
397
+ rmSync(extensionRoot, { recursive: true, force: true });
398
+ }
399
+ });
400
+
401
+ test('runCodexRestoreSourceAudit ignores short retained rollback text needles in VS Code storage search', () => {
402
+ const codexHome = makeTempDir('tl-audit-codex-');
403
+ const project = makeTempDir('tl-audit-project-');
404
+ const storage = makeTempDir('tl-audit-storage-');
405
+ const threadId = '019dfdef-0000-7000-8000-000000000104';
406
+ const longText = 'long retained rollback text that should be searched uniquely';
407
+ try {
408
+ writeRiskyRollbackRollout(codexHome, {
409
+ project,
410
+ threadId,
411
+ userMessages: ['go', longText],
412
+ });
413
+ writeFileSync(join(storage, 'short-only.txt'), 'go appears in many unrelated files');
414
+
415
+ const shortOnly = runCodexRestoreSourceAudit({
416
+ threadId,
417
+ codexHome,
418
+ projectPath: project,
419
+ vscodeStorageRoots: [storage],
420
+ vscodeExtensionRoots: [],
421
+ vscodeSettingsRoots: [],
422
+ vscodeLogRoots: [],
423
+ });
424
+
425
+ assert.equal(shortOnly.rollout.restoreSafety.status, 'risk');
426
+ assert.equal(shortOnly.rollout.restoreSafety.rollbackTextRetainedInCompacted, 2);
427
+ assert.equal(shortOnly.vscodeStorage.matches.length, 0);
428
+
429
+ writeFileSync(join(storage, 'long.txt'), longText);
430
+ const withLong = runCodexRestoreSourceAudit({
431
+ threadId,
432
+ codexHome,
433
+ projectPath: project,
434
+ vscodeStorageRoots: [storage],
435
+ vscodeExtensionRoots: [],
436
+ vscodeSettingsRoots: [],
437
+ vscodeLogRoots: [],
438
+ });
439
+
440
+ assert.equal(withLong.vscodeStorage.matches.length, 1);
441
+ assert.deepEqual(withLong.vscodeStorage.matches[0].needles, ['retained_rollback_text_1']);
442
+ } finally {
443
+ rmSync(codexHome, { recursive: true, force: true });
444
+ rmSync(project, { recursive: true, force: true });
445
+ rmSync(storage, { recursive: true, force: true });
446
+ }
447
+ });
448
+
449
+ test('runCodexRestoreSourceAudit inventories SQLite-backed VS Code storage matches', () => {
450
+ const codexHome = makeTempDir('tl-audit-codex-');
451
+ const project = makeTempDir('tl-audit-project-');
452
+ const storage = makeTempDir('tl-audit-storage-');
453
+ const threadId = '019dfdef-0000-7000-8000-000000000107';
454
+ const retainedText = 'long retained rollback text stored inside sqlite value';
455
+ try {
456
+ writeRiskyRollbackRollout(codexHome, {
457
+ project,
458
+ threadId,
459
+ userMessages: [retainedText],
460
+ });
461
+ const db = new DatabaseSync(join(storage, 'state.vscdb'));
462
+ db.exec('create table ItemTable (key text primary key, value text)');
463
+ db.prepare('insert into ItemTable (key, value) values (?, ?)').run(
464
+ `codex:persisted-atom:${threadId}`,
465
+ JSON.stringify({ threadId, retainedText }),
466
+ );
467
+ db.close();
468
+
469
+ const result = runCodexRestoreSourceAudit({
470
+ threadId,
471
+ codexHome,
472
+ projectPath: project,
473
+ vscodeStorageRoots: [storage],
474
+ vscodeExtensionRoots: [],
475
+ vscodeSettingsRoots: [],
476
+ vscodeLogRoots: [],
477
+ });
478
+
479
+ assert.equal(result.vscodeStorage.sqliteDatabases.length, 1);
480
+ assert.equal(result.vscodeStorage.sqliteDatabases[0].status, 'ok');
481
+ assert.deepEqual(
482
+ result.vscodeStorage.sqliteDatabases[0].tables.map((table) => table.name),
483
+ ['ItemTable'],
484
+ );
485
+ assert.deepEqual(
486
+ [...new Set(result.vscodeStorage.sqliteDatabases[0].matches.map((match) => match.needle))].sort(),
487
+ ['retained_rollback_text_1', 'thread_id'],
488
+ );
489
+ assert.equal(result.summary.vscodeStorageSqliteDatabases, 1);
490
+ assert.equal(result.summary.vscodeStorageSqliteDatabaseMatches, 3);
491
+ } finally {
492
+ rmSync(codexHome, { recursive: true, force: true });
493
+ rmSync(project, { recursive: true, force: true });
494
+ rmSync(storage, { recursive: true, force: true });
495
+ }
496
+ });
497
+
498
+ test('runCodexRestoreSourceAudit refuses when the rollout source is missing', () => {
499
+ const codexHome = makeTempDir('tl-audit-codex-');
500
+ const project = makeTempDir('tl-audit-project-');
501
+ try {
502
+ const result = runCodexRestoreSourceAudit({
503
+ threadId: '019dfdef-0000-7000-8000-000000000102',
504
+ codexHome,
505
+ projectPath: project,
506
+ vscodeStorageRoots: [],
507
+ vscodeExtensionRoots: [],
508
+ vscodeSettingsRoots: [],
509
+ vscodeLogRoots: [],
510
+ });
511
+
512
+ assert.equal(result.status, 'refused');
513
+ assert.equal(result.reason, 'codex_rollout_source_required');
514
+ assert.equal(result.restartSafe, false);
515
+ } finally {
516
+ rmSync(codexHome, { recursive: true, force: true });
517
+ rmSync(project, { recursive: true, force: true });
518
+ }
519
+ });
520
+
521
+ function writeRiskyRollbackRollout(codexHome, { project, threadId, userMessages }) {
522
+ const dir = join(codexHome, 'sessions', '2026', '05', '07');
523
+ mkdirSync(dir, { recursive: true });
524
+ const path = join(dir, `rollout-2026-05-07T00-00-00-${threadId}.jsonl`);
525
+ const rows = [
526
+ {
527
+ timestamp: '2026-05-07T00:00:00.000Z',
528
+ type: 'session_meta',
529
+ payload: {
530
+ id: threadId,
531
+ timestamp: '2026-05-07T00:00:00.000Z',
532
+ cwd: project,
533
+ source: 'vscode',
534
+ cli_version: '0.128.0-alpha.1',
535
+ },
536
+ },
537
+ ];
538
+ for (const [index, message] of userMessages.entries()) {
539
+ rows.push({
540
+ timestamp: `2026-05-07T00:00:0${index + 1}.000Z`,
541
+ type: 'event_msg',
542
+ payload: { type: 'user_message', message },
543
+ });
544
+ rows.push({
545
+ timestamp: `2026-05-07T00:00:0${index + 1}.100Z`,
546
+ type: 'event_msg',
547
+ payload: { type: 'task_started' },
548
+ });
549
+ rows.push({
550
+ timestamp: `2026-05-07T00:00:0${index + 1}.200Z`,
551
+ type: 'event_msg',
552
+ payload: { type: 'agent_message', message: `answer ${index + 1}` },
553
+ });
554
+ rows.push({
555
+ timestamp: `2026-05-07T00:00:0${index + 1}.300Z`,
556
+ type: 'event_msg',
557
+ payload: { type: 'task_complete' },
558
+ });
559
+ }
560
+ rows.push({
561
+ timestamp: '2026-05-07T00:00:10.000Z',
562
+ type: 'compacted',
563
+ payload: {
564
+ message: '',
565
+ replacement_history: userMessages.map((text) => ({
566
+ type: 'message',
567
+ role: 'user',
568
+ content: [{ type: 'input_text', text }],
569
+ })),
570
+ },
571
+ });
572
+ rows.push({
573
+ timestamp: '2026-05-07T00:00:11.000Z',
574
+ type: 'event_msg',
575
+ payload: { type: 'thread_rolled_back', num_turns: userMessages.length },
576
+ });
577
+ writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
578
+ return path;
579
+ }
580
+
581
+ test('codex-restore-source-audit CLI accepts env thread id and prints JSON', () => {
582
+ const home = makeTempDir('tl-audit-home-');
583
+ const codexHome = makeTempDir('tl-audit-codex-');
584
+ const project = makeTempDir('tl-audit-project-');
585
+ const extensionRoot = makeTempDir('tl-audit-empty-extension-');
586
+ const threadId = '019dfdef-0000-7000-8000-000000000103';
587
+ try {
588
+ writeCodexRollout(codexHome, { project, threadId, turnCount: 1 });
589
+ const result = spawnSync(
590
+ process.execPath,
591
+ [
592
+ join(REPO_ROOT, 'bin/throughline.mjs'),
593
+ 'codex-restore-source-audit',
594
+ '--codex-home',
595
+ codexHome,
596
+ '--vscode-extension-root',
597
+ extensionRoot,
598
+ '--json',
599
+ ],
600
+ {
601
+ cwd: project,
602
+ env: {
603
+ ...process.env,
604
+ HOME: home,
605
+ USERPROFILE: home,
606
+ CODEX_THREAD_ID: threadId,
607
+ },
608
+ encoding: 'utf8',
609
+ },
610
+ );
611
+
612
+ assert.equal(result.status, 0, result.stderr);
613
+ const payload = JSON.parse(result.stdout);
614
+ assert.equal(payload.status, 'restore-source-audit-complete');
615
+ assert.equal(payload.threadId, threadId);
616
+ assert.equal(payload.rollout.capturedTurns, 1);
617
+ } finally {
618
+ rmSync(home, { recursive: true, force: true });
619
+ rmSync(codexHome, { recursive: true, force: true });
620
+ rmSync(project, { recursive: true, force: true });
621
+ rmSync(extensionRoot, { recursive: true, force: true });
622
+ }
623
+ });