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
@@ -1,21 +1,39 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
3
4
  import { mkdtempSync, rmSync, writeFileSync, mkdirSync, utimesSync } from 'node:fs';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { join } from 'node:path';
6
7
 
7
8
  import { _internal } from './doctor.mjs';
8
9
 
9
- const { parseArgs, formatAgo, formatBytes, findLatestJsonlInSameDir, isPidAlive } = _internal;
10
+ const {
11
+ parseArgs,
12
+ formatAgo,
13
+ formatBytes,
14
+ findLatestJsonlInSameDir,
15
+ isPidAlive,
16
+ readCodexHookDiagnosis,
17
+ readVsCodeMonitorTaskDiagnosis,
18
+ runCodexDiagnosis,
19
+ runTrimDiagnosis,
20
+ buildCodexContextRefreshDiagnosis,
21
+ readCodexHostPrimitiveDiagnosis,
22
+ } = _internal;
10
23
 
11
24
  // ─── parseArgs ──────────────────────────────────────────────────────
12
25
 
13
26
  test('parseArgs: 引数なしは session null', () => {
14
- assert.deepEqual(parseArgs([]), { session: null });
27
+ assert.deepEqual(parseArgs([]), { session: null, trim: false, host: 'unknown', codex: false });
15
28
  });
16
29
 
17
30
  test('parseArgs: --session <prefix>', () => {
18
- assert.deepEqual(parseArgs(['--session', 'abc']), { session: 'abc' });
31
+ assert.deepEqual(parseArgs(['--session', 'abc']), {
32
+ session: 'abc',
33
+ trim: false,
34
+ host: 'unknown',
35
+ codex: false,
36
+ });
19
37
  });
20
38
 
21
39
  test('parseArgs: --session の値欠落は throw', () => {
@@ -26,12 +44,533 @@ test('parseArgs: --session の次が別フラグなら throw', () => {
26
44
  assert.throws(() => parseArgs(['--session', '--other']), /session id prefix/);
27
45
  });
28
46
 
47
+ test('parseArgs: --trim --host <host>', () => {
48
+ assert.deepEqual(parseArgs(['--trim', '--host', 'claude']), {
49
+ session: null,
50
+ trim: true,
51
+ host: 'claude',
52
+ codex: false,
53
+ });
54
+ });
55
+
56
+ test('parseArgs: --host は known host のみ', () => {
57
+ assert.throws(() => parseArgs(['--trim', '--host', 'robot']), /claude, codex, or unknown/);
58
+ });
59
+
60
+ test('runTrimDiagnosis: codex reports missing current thread identity', () => {
61
+ const output = captureStdout(() =>
62
+ runTrimDiagnosis('codex', {}, { auditRunner: blockedHostPrimitiveAudit }),
63
+ );
64
+
65
+ assert.match(output, /current Codex thread:\s+not detected/);
66
+ assert.match(output, /host primitive audit:\s+host-primitive-audit-blocked/);
67
+ assert.match(output, /current-thread non-resurrection:\s+no/);
68
+ assert.match(output, /repair contract:\s+blocked-missing-current-thread-non-resurrection-guarantee/);
69
+ assert.match(output, /throughline trim --dry-run --host codex --codex-thread-id <id>/);
70
+ assert.match(output, /throughline trim --preflight --host codex --codex-thread-id <id>/);
71
+ assert.match(output, /throughline trim --execute --host codex/);
72
+ assert.match(output, /fresh-thread continuation path:/);
73
+ assert.match(output, /status:\s+fresh-thread-handoff-available/);
74
+ assert.match(output, /safety scope:\s+fresh_thread_handoff_no_current_thread_mutation/);
75
+ assert.match(output, /throughline codex-handoff-start --session codex:<thread-id>/);
76
+ assert.match(output, /throughline codex-handoff-smoke --session codex:<thread-id>/);
77
+ assert.match(output, /throughline codex-handoff-model-smoke --session codex:<thread-id> --dry-run --json/);
78
+ assert.match(output, /throughline codex-resume --session codex:<thread-id> --format handoff/);
79
+ assert.match(output, /start a new Codex thread with that handoff context only if desired/);
80
+ });
81
+
82
+ test('runTrimDiagnosis: codex reports env current thread identity', () => {
83
+ const output = captureStdout(() =>
84
+ runTrimDiagnosis(
85
+ 'codex',
86
+ {
87
+ THROUGHLINE_CODEX_THREAD_ID: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
88
+ },
89
+ { auditRunner: blockedHostPrimitiveAudit },
90
+ ),
91
+ );
92
+
93
+ assert.match(
94
+ output,
95
+ /current Codex thread:\s+019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 \(env:THROUGHLINE_CODEX_THREAD_ID\)/,
96
+ );
97
+ assert.match(output, /throughline trim --dry-run --host codex/);
98
+ assert.match(output, /throughline trim --preflight --host codex/);
99
+ assert.match(output, /host primitive audit:\s+host-primitive-audit-blocked/);
100
+ assert.match(output, /repair contract:\s+blocked-missing-current-thread-non-resurrection-guarantee/);
101
+ assert.match(output, /throughline trim --execute --host codex/);
102
+ assert.match(
103
+ output,
104
+ /throughline codex-resume --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 --format handoff/,
105
+ );
106
+ assert.match(
107
+ output,
108
+ /throughline codex-handoff-start --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/,
109
+ );
110
+ assert.match(
111
+ output,
112
+ /throughline codex-handoff-smoke --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/,
113
+ );
114
+ assert.match(
115
+ output,
116
+ /throughline codex-handoff-model-smoke --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 --dry-run --json/,
117
+ );
118
+ assert.doesNotMatch(output, /--codex-thread-id <id>/);
119
+ });
120
+
121
+ test('parseArgs: --codex', () => {
122
+ assert.deepEqual(parseArgs(['--codex']), {
123
+ session: null,
124
+ trim: false,
125
+ host: 'unknown',
126
+ codex: true,
127
+ });
128
+ });
129
+
130
+ test('runCodexDiagnosis: reports env thread and captured DB session', () => {
131
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
132
+ const cwd = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-project-'));
133
+ try {
134
+ const output = captureStdout(() =>
135
+ runCodexDiagnosis({
136
+ cwd,
137
+ env: {
138
+ CODEX_HOME: codexHome,
139
+ CODEX_THREAD_ID: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
140
+ },
141
+ db: {
142
+ prepare(sql) {
143
+ return {
144
+ get(projectPath) {
145
+ assert.equal(projectPath, cwd);
146
+ if (sql.includes('COUNT(*)')) return { count: 1 };
147
+ return { session_id: 'codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9', updated_at: 1000 };
148
+ },
149
+ };
150
+ },
151
+ },
152
+ auditRunner: blockedHostPrimitiveAudit,
153
+ }),
154
+ );
155
+
156
+ assert.match(output, /\[Codex primary\]/);
157
+ assert.match(output, /Codex hooks feature:\s+not enabled/);
158
+ assert.match(output, /Codex Stop hook:\s+not registered/);
159
+ assert.match(output, /VSCode monitor task:\s+not registered/);
160
+ assert.match(output, /created by the next VSCode hook event/);
161
+ assert.match(output, /current Codex thread:\s+019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 \(env:CODEX_THREAD_ID\)/);
162
+ assert.match(output, /captured DB sessions:\s+1/);
163
+ assert.match(output, /latest DB session:\s+codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/);
164
+ assert.match(output, /context refresh:\s+not ready/);
165
+ assert.match(output, /host primitive audit:\s+host-primitive-audit-blocked/);
166
+ assert.match(output, /current-thread non-resurrection:\s+no/);
167
+ assert.match(output, /repair contract:\s+blocked-missing-current-thread-non-resurrection-guarantee/);
168
+ assert.match(output, /throughline codex-capture --codex-thread-id 019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/);
169
+ assert.match(output, /throughline codex-handoff-start --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/);
170
+ assert.match(output, /throughline codex-handoff-smoke --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/);
171
+ assert.match(output, /throughline codex-handoff-model-smoke --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 --dry-run --json/);
172
+ assert.match(output, /throughline codex-resume --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 --format handoff/);
173
+ assert.match(output, /throughline codex-resume --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/);
174
+ assertInOrder(output, [
175
+ 'throughline codex-capture --codex-thread-id 019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
176
+ 'throughline codex-handoff-start --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
177
+ 'throughline codex-handoff-smoke --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
178
+ 'throughline codex-handoff-model-smoke --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 --dry-run --json',
179
+ 'throughline codex-resume --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9 --format handoff',
180
+ 'throughline codex-resume --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
181
+ ]);
182
+ assert.match(output, /THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE=1 throughline codex-handoff-model-smoke --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/);
183
+ assert.match(output, /throughline codex-host-primitive-audit/);
184
+ } finally {
185
+ rmSync(codexHome, { recursive: true, force: true });
186
+ rmSync(cwd, { recursive: true, force: true });
187
+ }
188
+ });
189
+
190
+ test('runCodexDiagnosis: prints optional fresh-thread handoff status when DB memory is ready', () => {
191
+ const db = makeMemoryDb();
192
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
193
+ try {
194
+ seedCodexMemory(db);
195
+ const output = captureStdout(() =>
196
+ runCodexDiagnosis({
197
+ cwd: '/repo',
198
+ env: {
199
+ CODEX_HOME: codexHome,
200
+ CODEX_THREAD_ID: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
201
+ },
202
+ db,
203
+ auditRunner: blockedHostPrimitiveAudit,
204
+ }),
205
+ );
206
+
207
+ assert.match(output, /context refresh:\s+ready/);
208
+ assert.match(output, /new-thread handoff:\s+ready/);
209
+ assert.match(output, /safe continuation:\s+fresh-thread-handoff-available/);
210
+ assert.match(output, /throughline codex-handoff-start --session codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9/);
211
+ } finally {
212
+ db.close();
213
+ rmSync(codexHome, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test('readCodexHostPrimitiveDiagnosis reports audit failure explicitly', () => {
218
+ const diagnosis = readCodexHostPrimitiveDiagnosis({
219
+ auditRunner() {
220
+ throw new Error('schema unavailable');
221
+ },
222
+ });
223
+
224
+ assert.deepEqual(diagnosis, {
225
+ status: 'unavailable',
226
+ reason: 'schema unavailable',
227
+ hasCurrentThreadRemediationPrimitive: false,
228
+ hasCurrentThreadNonResurrectionPrimitive: false,
229
+ repairContractStatus: 'unavailable',
230
+ methodCount: null,
231
+ });
232
+ });
233
+
234
+ test('readVsCodeMonitorTaskDiagnosis: reports registered folderOpen monitor and reload note', () => {
235
+ const cwd = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-project-'));
236
+ try {
237
+ mkdirSync(join(cwd, '.vscode'), { recursive: true });
238
+ writeFileSync(
239
+ join(cwd, '.vscode', 'tasks.json'),
240
+ JSON.stringify(
241
+ {
242
+ version: '2.0.0',
243
+ tasks: [
244
+ {
245
+ label: 'Throughline Monitor',
246
+ type: 'shell',
247
+ command: process.execPath,
248
+ args: ['throughline', 'monitor'],
249
+ runOptions: { runOn: 'folderOpen' },
250
+ },
251
+ ],
252
+ },
253
+ null,
254
+ 2,
255
+ ) + '\n',
256
+ );
257
+
258
+ const diagnosis = readVsCodeMonitorTaskDiagnosis(cwd);
259
+ assert.equal(diagnosis.status, 'registered');
260
+ assert.equal(diagnosis.path, join(cwd, '.vscode', 'tasks.json'));
261
+ assert.equal(diagnosis.runOn, 'folderOpen');
262
+ assert.match(diagnosis.note, /Developer: Reload Window/);
263
+ } finally {
264
+ rmSync(cwd, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ test('buildCodexContextRefreshDiagnosis reports original /tl memory contract', () => {
269
+ const db = makeMemoryDb();
270
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
271
+ try {
272
+ seedCodexMemory(db);
273
+ const diagnosis = buildCodexContextRefreshDiagnosis({
274
+ db,
275
+ cwd: '/repo',
276
+ codexHome,
277
+ identity: {
278
+ codexThreadId: '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
279
+ codexThreadIdSource: 'env:CODEX_THREAD_ID',
280
+ },
281
+ });
282
+
283
+ assert.equal(diagnosis.status, 'ready');
284
+ assert.equal(diagnosis.injectMemorySource, 'throughline-db');
285
+ assert.equal(diagnosis.memoryContract, 'older L1 + latest 20 L2 full bodies + L3 references only');
286
+ assert.equal(diagnosis.l1Summaries, 1);
287
+ assert.equal(diagnosis.recentBodies, '2 rows (latest 20 turns)');
288
+ assert.equal(diagnosis.l3References, 1);
289
+ assert.equal(diagnosis.handoffSmoke.status, 'ready');
290
+ assert.equal(diagnosis.handoffSmoke.reason, 'fresh_thread_handoff_prompt_ready');
291
+ assert.equal(diagnosis.safeContinuationStatus, 'fresh-thread-handoff-available');
292
+ assert.ok(diagnosis.handoffSmoke.promptChars > 0);
293
+ assert.ok(diagnosis.handoffSmoke.estimatedTokens > 0);
294
+ } finally {
295
+ db.close();
296
+ rmSync(codexHome, { recursive: true, force: true });
297
+ }
298
+ });
299
+
300
+ test('buildCodexContextRefreshDiagnosis keeps ready label when restore safety is diagnostic-only', () => {
301
+ const db = makeMemoryDb();
302
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
303
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
304
+ try {
305
+ seedCodexMemory(db);
306
+ writeCodexRollout(codexHome, {
307
+ project: '/repo',
308
+ threadId,
309
+ turnCount: 22,
310
+ restoreRisk: true,
311
+ });
312
+ const diagnosis = buildCodexContextRefreshDiagnosis({
313
+ db,
314
+ cwd: '/repo',
315
+ codexHome,
316
+ identity: {
317
+ codexThreadId: threadId,
318
+ codexThreadIdSource: 'env:CODEX_THREAD_ID',
319
+ },
320
+ });
321
+
322
+ assert.equal(diagnosis.status, 'ready');
323
+ assert.equal(diagnosis.blockedReason, null);
324
+ assert.equal(diagnosis.injectMemorySource, 'throughline-db');
325
+ } finally {
326
+ db.close();
327
+ rmSync(codexHome, { recursive: true, force: true });
328
+ }
329
+ });
330
+
331
+ test('readCodexHookDiagnosis detects legacy bare Throughline Codex Stop hook', () => {
332
+ const codexHome = mkdtempSync(join(tmpdir(), 'tl-doctor-codex-home-'));
333
+ try {
334
+ mkdirSync(join(codexHome), { recursive: true });
335
+ writeFileSync(
336
+ join(codexHome, 'hooks.json'),
337
+ JSON.stringify(
338
+ {
339
+ hooks: {
340
+ Stop: [
341
+ {
342
+ hooks: [
343
+ {
344
+ type: 'command',
345
+ command: 'throughline codex-hook stop',
346
+ timeoutSec: 300,
347
+ async: true,
348
+ },
349
+ ],
350
+ },
351
+ ],
352
+ },
353
+ },
354
+ null,
355
+ 2,
356
+ ) + '\n',
357
+ );
358
+ writeFileSync(join(codexHome, 'config.toml'), '[features]\ncodex_hooks = true\n');
359
+
360
+ const diagnosis = readCodexHookDiagnosis(codexHome);
361
+ assert.equal(diagnosis.featureEnabled, true);
362
+ assert.equal(diagnosis.managedStopHooks.length, 1);
363
+ assert.equal(diagnosis.legacyManagedStopHooks.length, 1);
364
+ } finally {
365
+ rmSync(codexHome, { recursive: true, force: true });
366
+ }
367
+ });
368
+
29
369
  // ─── formatAgo ──────────────────────────────────────────────────────
30
370
 
31
371
  test('formatAgo: 60 秒未満は秒表示', () => {
32
372
  assert.equal(formatAgo(30_000), '30s ago');
33
373
  });
34
374
 
375
+ function captureStdout(fn) {
376
+ const originalWrite = process.stdout.write.bind(process.stdout);
377
+ let output = '';
378
+ process.stdout.write = (chunk) => {
379
+ output += String(chunk);
380
+ return true;
381
+ };
382
+ try {
383
+ fn();
384
+ } finally {
385
+ process.stdout.write = originalWrite;
386
+ }
387
+ return output;
388
+ }
389
+
390
+ function assertInOrder(text, expected) {
391
+ let cursor = -1;
392
+ for (const needle of expected) {
393
+ const index = text.indexOf(needle, cursor + 1);
394
+ assert.notEqual(index, -1, `missing expected output: ${needle}`);
395
+ assert.ok(index > cursor, `expected "${needle}" after previous output`);
396
+ cursor = index;
397
+ }
398
+ }
399
+
400
+ function blockedHostPrimitiveAudit() {
401
+ return {
402
+ status: 'host-primitive-audit-blocked',
403
+ reason: 'no_current_thread_restore_non_resurrection_primitive',
404
+ methodCount: 89,
405
+ facts: {
406
+ hasCurrentThreadRemediationPrimitive: false,
407
+ hasCurrentThreadNonResurrectionPrimitive: false,
408
+ },
409
+ repairContract: {
410
+ status: 'blocked-missing-current-thread-non-resurrection-guarantee',
411
+ },
412
+ };
413
+ }
414
+
415
+ function writeCodexRollout(codexHome, { project, threadId, turnCount, restoreRisk = false }) {
416
+ const dir = join(codexHome, 'sessions', '2026', '05', '06');
417
+ mkdirSync(dir, { recursive: true });
418
+ const path = join(dir, `rollout-2026-05-06T09-40-50-${threadId}.jsonl`);
419
+ const rows = [
420
+ {
421
+ timestamp: '2026-05-06T00:40:50.000Z',
422
+ type: 'session_meta',
423
+ payload: {
424
+ id: threadId,
425
+ timestamp: '2026-05-06T00:40:50.000Z',
426
+ cwd: project,
427
+ source: 'vscode',
428
+ },
429
+ },
430
+ ];
431
+
432
+ for (let turn = 1; turn <= turnCount; turn++) {
433
+ rows.push({
434
+ timestamp: `2026-05-06T00:41:${String(turn).padStart(2, '0')}.000Z`,
435
+ type: 'event_msg',
436
+ payload: {
437
+ type: 'user_message',
438
+ message: `codex user turn ${turn}`,
439
+ },
440
+ });
441
+ rows.push({
442
+ timestamp: `2026-05-06T00:41:${String(turn).padStart(2, '0')}.100Z`,
443
+ type: 'event_msg',
444
+ payload: { type: 'task_started' },
445
+ });
446
+ rows.push({
447
+ timestamp: `2026-05-06T00:41:${String(turn).padStart(2, '0')}.200Z`,
448
+ type: 'event_msg',
449
+ payload: {
450
+ type: 'agent_message',
451
+ message: `codex assistant turn ${turn}`,
452
+ },
453
+ });
454
+ rows.push({
455
+ timestamp: `2026-05-06T00:41:${String(turn).padStart(2, '0')}.300Z`,
456
+ type: 'event_msg',
457
+ payload: { type: 'task_complete' },
458
+ });
459
+ }
460
+
461
+ if (restoreRisk) {
462
+ const riskyText = `codex user turn ${turnCount}`;
463
+ rows.push({
464
+ timestamp: '2026-05-06T00:42:00.000Z',
465
+ type: 'compacted',
466
+ payload: {
467
+ message: '',
468
+ replacement_history: [
469
+ {
470
+ type: 'message',
471
+ role: 'user',
472
+ content: [{ type: 'input_text', text: riskyText }],
473
+ },
474
+ ],
475
+ },
476
+ });
477
+ rows.push({
478
+ timestamp: '2026-05-06T00:42:00.100Z',
479
+ type: 'event_msg',
480
+ payload: { type: 'context_compacted' },
481
+ });
482
+ rows.push({
483
+ timestamp: '2026-05-06T00:42:00.200Z',
484
+ type: 'event_msg',
485
+ payload: { type: 'thread_rolled_back', num_turns: 1 },
486
+ });
487
+ }
488
+
489
+ writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
490
+ return path;
491
+ }
492
+
493
+ function makeMemoryDb() {
494
+ const db = new DatabaseSync(':memory:');
495
+ db.exec(`
496
+ CREATE TABLE sessions (
497
+ session_id TEXT PRIMARY KEY,
498
+ project_path TEXT NOT NULL,
499
+ status TEXT NOT NULL DEFAULT 'active',
500
+ created_at INTEGER NOT NULL,
501
+ updated_at INTEGER NOT NULL,
502
+ merged_into TEXT
503
+ );
504
+ CREATE TABLE skeletons (
505
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
506
+ session_id TEXT NOT NULL,
507
+ origin_session_id TEXT,
508
+ turn_number INTEGER NOT NULL,
509
+ role TEXT NOT NULL,
510
+ summary TEXT NOT NULL,
511
+ created_at INTEGER NOT NULL
512
+ );
513
+ CREATE TABLE bodies (
514
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
515
+ session_id TEXT NOT NULL,
516
+ origin_session_id TEXT NOT NULL,
517
+ turn_number INTEGER NOT NULL,
518
+ role TEXT NOT NULL,
519
+ text TEXT NOT NULL,
520
+ token_count INTEGER,
521
+ created_at INTEGER NOT NULL
522
+ );
523
+ CREATE TABLE details (
524
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
525
+ session_id TEXT NOT NULL,
526
+ origin_session_id TEXT,
527
+ turn_number INTEGER,
528
+ tool_name TEXT NOT NULL,
529
+ input_text TEXT,
530
+ output_text TEXT,
531
+ token_count INTEGER NOT NULL DEFAULT 0,
532
+ created_at INTEGER NOT NULL,
533
+ kind TEXT,
534
+ source_id TEXT
535
+ );
536
+ `);
537
+ return db;
538
+ }
539
+
540
+ function seedCodexMemory(db) {
541
+ db.prepare(
542
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
543
+ VALUES ('codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9', '/repo', 'active', 1, 2)`,
544
+ ).run();
545
+ db.prepare(
546
+ `INSERT INTO skeletons
547
+ (session_id, origin_session_id, turn_number, role, summary, created_at)
548
+ VALUES ('codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
549
+ 'codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
550
+ 1, 'assistant', 'older L1 summary', 1000)`,
551
+ ).run();
552
+ for (const [role, text, createdAt] of [
553
+ ['user', 'recent user L2', 2000],
554
+ ['assistant', 'recent assistant L2', 2100],
555
+ ]) {
556
+ db.prepare(
557
+ `INSERT INTO bodies
558
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
559
+ VALUES ('codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
560
+ 'codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
561
+ 2, ?, ?, 1, ?)`,
562
+ ).run(role, text, createdAt);
563
+ }
564
+ db.prepare(
565
+ `INSERT INTO details
566
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
567
+ token_count, created_at, kind, source_id)
568
+ VALUES ('codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
569
+ 'codex:019dfaba-f87e-7f41-a144-d5ca7c6dd7f9',
570
+ 2, 'exec_command', 'input', 'large output', 10, 2200, 'tool_output', 'detail-1')`,
571
+ ).run();
572
+ }
573
+
35
574
  test('formatAgo: 60 分未満は分表示', () => {
36
575
  assert.equal(formatAgo(5 * 60_000), '5m ago');
37
576
  });
@@ -0,0 +1,78 @@
1
+ import { getDb } from '../db.mjs';
2
+ import { buildHandoffRecord } from '../handoff-record.mjs';
3
+ import { toThroughlineHandoffBlock } from '../codex-handoff.mjs';
4
+
5
+ function parseArgs(args) {
6
+ const out = {
7
+ sessionId: null,
8
+ hostMode: 'claude-primary',
9
+ };
10
+
11
+ for (let i = 0; i < args.length; i++) {
12
+ const arg = args[i];
13
+ if (arg === '--session') {
14
+ const value = args[++i];
15
+ if (!value || value.startsWith('-')) {
16
+ throw new Error('--session requires a session id');
17
+ }
18
+ out.sessionId = value;
19
+ } else if (arg === '--host-mode') {
20
+ const value = args[++i];
21
+ if (!['claude-primary', 'codex-primary', 'unknown'].includes(value)) {
22
+ throw new Error('--host-mode must be claude-primary, codex-primary, or unknown');
23
+ }
24
+ out.hostMode = value;
25
+ } else if (!arg.startsWith('-') && !out.sessionId) {
26
+ out.sessionId = arg;
27
+ } else {
28
+ throw new Error(`unknown argument: ${arg}`);
29
+ }
30
+ }
31
+
32
+ return out;
33
+ }
34
+
35
+ function findLatestSessionId(db, projectPath) {
36
+ const row = db
37
+ .prepare(
38
+ `SELECT session_id
39
+ FROM sessions
40
+ WHERE lower(project_path) = lower(?)
41
+ ORDER BY updated_at DESC
42
+ LIMIT 1`,
43
+ )
44
+ .get(projectPath);
45
+ return row?.session_id ?? null;
46
+ }
47
+
48
+ export function run(args) {
49
+ let parsed;
50
+ try {
51
+ parsed = parseArgs(args);
52
+ } catch (err) {
53
+ const msg = err instanceof Error ? err.message : 'unknown';
54
+ process.stderr.write(`[handoff-preview] ${msg}\n`);
55
+ process.exit(1);
56
+ }
57
+
58
+ const db = getDb();
59
+ const sessionId = parsed.sessionId ?? findLatestSessionId(db, process.cwd());
60
+ if (!sessionId) {
61
+ process.stderr.write(
62
+ '[handoff-preview] no session found for this project. Pass --session <id> explicitly.\n',
63
+ );
64
+ process.exit(1);
65
+ }
66
+
67
+ const record = buildHandoffRecord(db, {
68
+ sessionId,
69
+ isInheritance: false,
70
+ });
71
+ if (!record) {
72
+ process.stderr.write(`[handoff-preview] no handoff memory found for session ${sessionId}\n`);
73
+ process.exit(1);
74
+ }
75
+
76
+ const block = toThroughlineHandoffBlock(record, { hostMode: parsed.hostMode });
77
+ process.stdout.write(JSON.stringify(block, null, 2) + '\n');
78
+ }