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.
- package/.claude/commands/tl-trim.md +42 -0
- package/.codex-sidecar.yml +62 -0
- package/CHANGELOG.md +583 -0
- package/README.ja.md +42 -5
- package/README.md +383 -23
- package/bin/throughline.mjs +168 -4
- package/codex/skills/throughline/SKILL.md +157 -0
- package/codex/skills/throughline/agents/openai.yaml +7 -0
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +146 -0
- package/docs/L1_L2_L3_REDESIGN.md +415 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +184 -0
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
- package/docs/archive/CONCEPT.md +476 -0
- package/docs/archive/EXPERIMENT.md +371 -0
- package/docs/archive/README.md +22 -0
- package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
- package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
- package/docs/throughline-handoff-context.example.json +57 -0
- package/docs/throughline-rollback-context-trim-insight.md +455 -0
- package/package.json +6 -2
- package/src/cli/codex-capture.mjs +95 -0
- package/src/cli/codex-handoff-model-smoke.mjs +292 -0
- package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
- package/src/cli/codex-handoff-smoke.mjs +163 -0
- package/src/cli/codex-handoff-smoke.test.mjs +149 -0
- package/src/cli/codex-handoff-start.mjs +291 -0
- package/src/cli/codex-handoff-start.test.mjs +194 -0
- package/src/cli/codex-hook.mjs +276 -0
- package/src/cli/codex-hook.test.mjs +293 -0
- package/src/cli/codex-host-primitive-audit.mjs +110 -0
- package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
- package/src/cli/codex-restore-smoke.mjs +357 -0
- package/src/cli/codex-restore-source-audit.mjs +304 -0
- package/src/cli/codex-resume.mjs +138 -0
- package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
- package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
- package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
- package/src/cli/codex-sidecar-dry-run.mjs +85 -0
- package/src/cli/codex-summarize.mjs +224 -0
- package/src/cli/codex-threads.mjs +89 -0
- package/src/cli/codex-visibility-smoke.mjs +196 -0
- package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
- package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
- package/src/cli/doctor.mjs +503 -1
- package/src/cli/doctor.test.mjs +542 -3
- package/src/cli/handoff-preview.mjs +78 -0
- package/src/cli/help.test.mjs +64 -0
- package/src/cli/install.mjs +227 -4
- package/src/cli/install.test.mjs +207 -4
- package/src/cli/trim.mjs +564 -0
- package/src/codex-app-server.mjs +1816 -0
- package/src/codex-app-server.test.mjs +512 -0
- package/src/codex-auto-refresh.mjs +194 -0
- package/src/codex-auto-refresh.test.mjs +182 -0
- package/src/codex-capture.mjs +235 -0
- package/src/codex-capture.test.mjs +393 -0
- package/src/codex-handoff-model-smoke.mjs +114 -0
- package/src/codex-handoff-model-smoke.test.mjs +89 -0
- package/src/codex-handoff-smoke.mjs +124 -0
- package/src/codex-handoff-smoke.test.mjs +103 -0
- package/src/codex-handoff.mjs +331 -0
- package/src/codex-handoff.test.mjs +220 -0
- package/src/codex-host-primitive-audit.mjs +374 -0
- package/src/codex-host-primitive-audit.test.mjs +208 -0
- package/src/codex-restore-smoke.test.mjs +639 -0
- package/src/codex-restore-source-audit.mjs +1348 -0
- package/src/codex-restore-source-audit.test.mjs +623 -0
- package/src/codex-resume.test.mjs +242 -0
- package/src/codex-rollout-memory.mjs +711 -0
- package/src/codex-rollout-memory.test.mjs +610 -0
- package/src/codex-sidecar-cli.test.mjs +75 -0
- package/src/codex-sidecar.mjs +246 -0
- package/src/codex-sidecar.test.mjs +172 -0
- package/src/codex-summarize.test.mjs +143 -0
- package/src/codex-thread-identity.mjs +23 -0
- package/src/codex-thread-index.mjs +173 -0
- package/src/codex-thread-index.test.mjs +164 -0
- package/src/codex-usage.mjs +110 -0
- package/src/codex-usage.test.mjs +140 -0
- package/src/codex-visibility-smoke.test.mjs +222 -0
- package/src/codex-vscode-restore-smoke.mjs +206 -0
- package/src/codex-vscode-restore-smoke.test.mjs +325 -0
- package/src/codex-vscode-rollback-smoke.mjs +90 -0
- package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
- package/src/db-schema.test.mjs +97 -0
- package/src/haiku-summarizer.mjs +267 -26
- package/src/haiku-summarizer.test.mjs +282 -0
- package/src/handoff-preview.test.mjs +108 -0
- package/src/handoff-record.mjs +294 -0
- package/src/handoff-record.test.mjs +226 -0
- package/src/hook-entrypoints.test.mjs +326 -0
- package/src/package-files.test.mjs +19 -0
- package/src/prompt-submit.mjs +9 -6
- package/src/resume-context.mjs +44 -140
- package/src/resume-context.test.mjs +172 -0
- package/src/session-start.mjs +8 -5
- package/src/state-file.mjs +50 -6
- package/src/state-file.test.mjs +50 -0
- package/src/token-monitor.mjs +14 -10
- package/src/token-monitor.test.mjs +27 -0
- package/src/trim-cli.test.mjs +1584 -0
- package/src/trim-model.mjs +584 -0
- package/src/trim-model.test.mjs +568 -0
- package/src/turn-processor.mjs +17 -10
- package/src/vscode-task.mjs +33 -10
- package/src/vscode-task.test.mjs +19 -9
|
@@ -0,0 +1,282 @@
|
|
|
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 { summarizeToL1 } from './haiku-summarizer.mjs';
|
|
7
|
+
|
|
8
|
+
function makeBin(dir, name, body) {
|
|
9
|
+
const path = join(dir, name);
|
|
10
|
+
writeFileSync(path, body);
|
|
11
|
+
chmodSync(path, 0o755);
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('summarizeToL1: returns empty fallback for blank input', () => {
|
|
16
|
+
const result = summarizeToL1('', {
|
|
17
|
+
projectPath: '/repo',
|
|
18
|
+
env: { ...process.env, THROUGHLINE_CODEX_SIDECAR_DISABLED: '1' },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
assert.equal(result.summary, '(no content)');
|
|
22
|
+
assert.equal(result.fromFallback, true);
|
|
23
|
+
assert.equal(result.source, 'empty');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('summarizeToL1: recursion guard returns raw L2 without spawning sidecar or haiku', () => {
|
|
27
|
+
const result = summarizeToL1('raw turn text', {
|
|
28
|
+
hostMode: 'claude-primary',
|
|
29
|
+
projectPath: '/repo',
|
|
30
|
+
env: {
|
|
31
|
+
...process.env,
|
|
32
|
+
THROUGHLINE_IN_HAIKU_SUBPROCESS: '1',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
assert.equal(result.summary, 'raw turn text');
|
|
37
|
+
assert.equal(result.fromFallback, true);
|
|
38
|
+
assert.equal(result.source, 'recursion_guard');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('summarizeToL1: uses codex-sidecar when diagnostics and run both succeed', () => {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-l1-sidecar-ok-'));
|
|
43
|
+
try {
|
|
44
|
+
const sidecar = makeBin(
|
|
45
|
+
dir,
|
|
46
|
+
'codex-sidecar',
|
|
47
|
+
`#!/usr/bin/env bash
|
|
48
|
+
if [ "$1" = "diagnostics" ]; then
|
|
49
|
+
printf '{"status":"ok"}\\n'
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
printf '{"status":"ok","summary":"sidecar summary"}\\n'
|
|
53
|
+
exit 0
|
|
54
|
+
`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const result = summarizeToL1('long enough turn text', {
|
|
58
|
+
hostMode: 'claude-primary',
|
|
59
|
+
projectPath: '/repo',
|
|
60
|
+
env: {
|
|
61
|
+
...process.env,
|
|
62
|
+
THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
assert.equal(result.summary, 'sidecar summary');
|
|
67
|
+
assert.equal(result.fromFallback, false);
|
|
68
|
+
assert.equal(result.source, 'codex-sidecar');
|
|
69
|
+
assert.equal(result.sidecarReason, 'sidecar_ok');
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('summarizeToL1: accepts stable SidecarResult summary without status field', () => {
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-l1-sidecar-result-'));
|
|
77
|
+
try {
|
|
78
|
+
const sidecar = makeBin(
|
|
79
|
+
dir,
|
|
80
|
+
'codex-sidecar',
|
|
81
|
+
`#!/usr/bin/env bash
|
|
82
|
+
if [ "$1" = "diagnostics" ]; then
|
|
83
|
+
printf '{"status":"ok"}\\n'
|
|
84
|
+
exit 0
|
|
85
|
+
fi
|
|
86
|
+
printf '{"summary":"stable sidecar summary","confidence":{"level":"high"},"recommendedNextAction":"continue"}\\n'
|
|
87
|
+
exit 0
|
|
88
|
+
`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const result = summarizeToL1('long enough turn text', {
|
|
92
|
+
hostMode: 'claude-primary',
|
|
93
|
+
projectPath: '/repo',
|
|
94
|
+
env: {
|
|
95
|
+
...process.env,
|
|
96
|
+
THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
assert.equal(result.summary, 'stable sidecar summary');
|
|
101
|
+
assert.equal(result.fromFallback, false);
|
|
102
|
+
assert.equal(result.source, 'codex-sidecar');
|
|
103
|
+
assert.equal(result.sidecarReason, 'sidecar_ok');
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('summarizeToL1: when sidecar is disabled, keeps current Haiku-compatible path', () => {
|
|
110
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-l1-haiku-'));
|
|
111
|
+
try {
|
|
112
|
+
makeBin(
|
|
113
|
+
dir,
|
|
114
|
+
'claude',
|
|
115
|
+
`#!/usr/bin/env bash
|
|
116
|
+
cat >/dev/null
|
|
117
|
+
printf 'haiku summary\\n'
|
|
118
|
+
`,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const result = summarizeToL1('long enough turn text', {
|
|
122
|
+
hostMode: 'claude-primary',
|
|
123
|
+
projectPath: '/repo',
|
|
124
|
+
env: {
|
|
125
|
+
...process.env,
|
|
126
|
+
PATH: `${dir}:${process.env.PATH ?? ''}`,
|
|
127
|
+
THROUGHLINE_CODEX_SIDECAR_DISABLED: '1',
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
assert.equal(result.summary, 'haiku summary');
|
|
132
|
+
assert.equal(result.fromFallback, false);
|
|
133
|
+
assert.equal(result.source, 'haiku');
|
|
134
|
+
assert.equal(result.sidecarReason, 'sidecar_disabled');
|
|
135
|
+
} finally {
|
|
136
|
+
rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('summarizeToL1: sidecar run failure keeps current Haiku-compatible path', () => {
|
|
141
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-l1-sidecar-fail-'));
|
|
142
|
+
try {
|
|
143
|
+
const sidecar = makeBin(
|
|
144
|
+
dir,
|
|
145
|
+
'codex-sidecar',
|
|
146
|
+
`#!/usr/bin/env bash
|
|
147
|
+
if [ "$1" = "diagnostics" ]; then
|
|
148
|
+
printf '{"status":"ok"}\\n'
|
|
149
|
+
exit 0
|
|
150
|
+
fi
|
|
151
|
+
printf 'sidecar failed\\n' >&2
|
|
152
|
+
exit 42
|
|
153
|
+
`,
|
|
154
|
+
);
|
|
155
|
+
makeBin(
|
|
156
|
+
dir,
|
|
157
|
+
'claude',
|
|
158
|
+
`#!/usr/bin/env bash
|
|
159
|
+
cat >/dev/null
|
|
160
|
+
printf 'haiku after sidecar failure\\n'
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const result = summarizeToL1('long enough turn text', {
|
|
165
|
+
hostMode: 'claude-primary',
|
|
166
|
+
projectPath: '/repo',
|
|
167
|
+
env: {
|
|
168
|
+
...process.env,
|
|
169
|
+
PATH: `${dir}:${process.env.PATH ?? ''}`,
|
|
170
|
+
THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.equal(result.summary, 'haiku after sidecar failure');
|
|
175
|
+
assert.equal(result.fromFallback, false);
|
|
176
|
+
assert.equal(result.source, 'haiku');
|
|
177
|
+
assert.equal(result.sidecarReason, 'sidecar_run_failed');
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('summarizeToL1: unknown host mode is an explicit error', () => {
|
|
184
|
+
assert.throws(
|
|
185
|
+
() =>
|
|
186
|
+
summarizeToL1('long enough turn text', {
|
|
187
|
+
projectPath: '/repo',
|
|
188
|
+
env: { ...process.env, THROUGHLINE_CODEX_SIDECAR_DISABLED: '1' },
|
|
189
|
+
}),
|
|
190
|
+
/requires hostMode/,
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('summarizeToL1: codex-primary uses Codex CLI backend', () => {
|
|
195
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-l1-codex-'));
|
|
196
|
+
try {
|
|
197
|
+
const argsFile = join(dir, 'args.txt');
|
|
198
|
+
const stdinFile = join(dir, 'stdin.txt');
|
|
199
|
+
const codex = makeBin(
|
|
200
|
+
dir,
|
|
201
|
+
'codex',
|
|
202
|
+
`#!/usr/bin/env bash
|
|
203
|
+
printf '%s\\n' "$@" > "${argsFile}"
|
|
204
|
+
cat > "${stdinFile}"
|
|
205
|
+
printf 'codex summary\\n'
|
|
206
|
+
`,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const result = summarizeToL1('long enough turn text', {
|
|
210
|
+
hostMode: 'codex-primary',
|
|
211
|
+
projectPath: dir,
|
|
212
|
+
env: {
|
|
213
|
+
...process.env,
|
|
214
|
+
THROUGHLINE_CODEX_CLI_BIN: codex,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.equal(result.summary, 'codex summary');
|
|
219
|
+
assert.equal(result.fromFallback, false);
|
|
220
|
+
assert.equal(result.source, 'codex-cli');
|
|
221
|
+
const argsText = readFileSync(argsFile, 'utf8').trim();
|
|
222
|
+
const argv = argsText.split('\n');
|
|
223
|
+
assert.deepEqual(argv.slice(0, 8), [
|
|
224
|
+
'exec',
|
|
225
|
+
'--ephemeral',
|
|
226
|
+
'--ignore-user-config',
|
|
227
|
+
'--ignore-rules',
|
|
228
|
+
'--skip-git-repo-check',
|
|
229
|
+
'--sandbox',
|
|
230
|
+
'read-only',
|
|
231
|
+
'-C',
|
|
232
|
+
]);
|
|
233
|
+
assert.equal(argv[8], dir);
|
|
234
|
+
assert.match(argsText, /Output contract/);
|
|
235
|
+
assert.equal(readFileSync(stdinFile, 'utf8'), 'long enough turn text');
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(dir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('summarizeToL1: codex-primary failure is not hidden by fallback', () => {
|
|
242
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-l1-codex-fail-'));
|
|
243
|
+
try {
|
|
244
|
+
const codex = makeBin(
|
|
245
|
+
dir,
|
|
246
|
+
'codex',
|
|
247
|
+
`#!/usr/bin/env bash
|
|
248
|
+
printf 'codex failed\\n' >&2
|
|
249
|
+
exit 42
|
|
250
|
+
`,
|
|
251
|
+
);
|
|
252
|
+
makeBin(
|
|
253
|
+
dir,
|
|
254
|
+
'claude',
|
|
255
|
+
`#!/usr/bin/env bash
|
|
256
|
+
printf 'should not run\\n'
|
|
257
|
+
`,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
assert.throws(
|
|
261
|
+
() =>
|
|
262
|
+
summarizeToL1('long enough turn text', {
|
|
263
|
+
hostMode: 'codex-primary',
|
|
264
|
+
projectPath: dir,
|
|
265
|
+
env: {
|
|
266
|
+
...process.env,
|
|
267
|
+
PATH: `${dir}:${process.env.PATH ?? ''}`,
|
|
268
|
+
THROUGHLINE_CODEX_CLI_BIN: codex,
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
(err) => {
|
|
272
|
+
assert.equal(err.source, 'codex-cli');
|
|
273
|
+
assert.equal(err.reason, 'codex_cli_failed');
|
|
274
|
+
assert.equal(err.exitCode, 42);
|
|
275
|
+
assert.match(err.stderr, /codex failed/);
|
|
276
|
+
return true;
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
} finally {
|
|
280
|
+
rmSync(dir, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
|
+
|
|
11
|
+
function makeTempHome() {
|
|
12
|
+
return mkdtempSync(join(tmpdir(), 'tl-handoff-preview-home-'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeTempProject() {
|
|
16
|
+
return mkdtempSync(join(tmpdir(), 'tl-handoff-preview-project-'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function seedDb(home, project) {
|
|
20
|
+
const originalHome = process.env.HOME;
|
|
21
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
22
|
+
process.env.HOME = home;
|
|
23
|
+
process.env.USERPROFILE = home;
|
|
24
|
+
try {
|
|
25
|
+
const mod = await import(`./db.mjs?handoffPreview=${Date.now()}-${Math.random()}`);
|
|
26
|
+
const db = mod.getDb();
|
|
27
|
+
db.prepare(
|
|
28
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
29
|
+
VALUES ('sess-preview', ?, 'active', 1, 2)`,
|
|
30
|
+
).run(project);
|
|
31
|
+
db.prepare(
|
|
32
|
+
`INSERT INTO bodies
|
|
33
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
34
|
+
VALUES ('sess-preview', 'sess-preview', 1, 'assistant', 'preview body', 3, 1000)`,
|
|
35
|
+
).run();
|
|
36
|
+
db.prepare(
|
|
37
|
+
`INSERT INTO details
|
|
38
|
+
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
39
|
+
token_count, created_at, kind, source_id)
|
|
40
|
+
VALUES ('sess-preview', 'sess-preview', 1, 'Bash', '{"command":"pwd"}',
|
|
41
|
+
NULL, 3, 1000, 'tool_input', 'toolu_preview')`,
|
|
42
|
+
).run();
|
|
43
|
+
db.close();
|
|
44
|
+
} finally {
|
|
45
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
46
|
+
else process.env.HOME = originalHome;
|
|
47
|
+
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
48
|
+
else process.env.USERPROFILE = originalUserProfile;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runPreview(home, project, args = []) {
|
|
53
|
+
return spawnSync(
|
|
54
|
+
process.execPath,
|
|
55
|
+
[join(REPO_ROOT, 'bin/throughline.mjs'), 'handoff-preview', ...args],
|
|
56
|
+
{
|
|
57
|
+
cwd: project,
|
|
58
|
+
env: {
|
|
59
|
+
...process.env,
|
|
60
|
+
HOME: home,
|
|
61
|
+
USERPROFILE: home,
|
|
62
|
+
},
|
|
63
|
+
encoding: 'utf8',
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('handoff-preview prints throughline_handoff JSON for explicit session', async () => {
|
|
69
|
+
const home = makeTempHome();
|
|
70
|
+
const project = makeTempProject();
|
|
71
|
+
try {
|
|
72
|
+
await seedDb(home, project);
|
|
73
|
+
const result = runPreview(home, project, [
|
|
74
|
+
'--session',
|
|
75
|
+
'sess-preview',
|
|
76
|
+
'--host-mode',
|
|
77
|
+
'unknown',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
assert.equal(result.status, 0, result.stderr);
|
|
81
|
+
const block = JSON.parse(result.stdout);
|
|
82
|
+
assert.equal(block.kind, 'throughline_handoff');
|
|
83
|
+
assert.equal(block.data.sessionId, 'sess-preview');
|
|
84
|
+
assert.equal(block.data.projectPath, project);
|
|
85
|
+
assert.equal(block.data.hostMode, 'unknown');
|
|
86
|
+
assert.equal(block.data.memory.recentBodies[0].text, 'preview body');
|
|
87
|
+
assert.equal(block.data.detailReferences[0].sourceId, 'toolu_preview');
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(project, { recursive: true, force: true });
|
|
90
|
+
rmSync(home, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('handoff-preview uses latest session for cwd when --session is omitted', async () => {
|
|
95
|
+
const home = makeTempHome();
|
|
96
|
+
const project = makeTempProject();
|
|
97
|
+
try {
|
|
98
|
+
await seedDb(home, project);
|
|
99
|
+
const result = runPreview(home, project);
|
|
100
|
+
|
|
101
|
+
assert.equal(result.status, 0, result.stderr);
|
|
102
|
+
const block = JSON.parse(result.stdout);
|
|
103
|
+
assert.equal(block.data.sessionId, 'sess-preview');
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(project, { recursive: true, force: true });
|
|
106
|
+
rmSync(home, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-neutral handoff projection.
|
|
3
|
+
*
|
|
4
|
+
* This module reads the same persisted memory that Claude resume-context uses,
|
|
5
|
+
* but returns a stable object instead of Claude-facing Markdown. It is not
|
|
6
|
+
* persisted to DB; adapters can render it for Claude, Codex, or diagnostics.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const HANDOFF_RECORD_VERSION = 1;
|
|
10
|
+
export const N_RECENT_L2 = 20;
|
|
11
|
+
export const CODEX_SESSION_PREFIX = 'codex:';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INTENT = 'continue implementation';
|
|
14
|
+
const DEFAULT_CONSTRAINTS = [
|
|
15
|
+
'preserve existing Claude Code hook, slash command, transcript, baton, and resume behavior',
|
|
16
|
+
'add Codex support as adapter/projection; do not rename Claude-facing DB fields or commands',
|
|
17
|
+
'do not treat unverified rollback/inject host behavior as a confirmed implementation contract',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Unix ms を HH:MM:SS 形式に変換する。
|
|
22
|
+
*/
|
|
23
|
+
export function formatTime(unixMs) {
|
|
24
|
+
const d = new Date(unixMs);
|
|
25
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
26
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
27
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
28
|
+
return `${hh}:${mm}:${ss}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadSession(db, sessionId) {
|
|
32
|
+
try {
|
|
33
|
+
return db
|
|
34
|
+
.prepare(
|
|
35
|
+
`SELECT session_id, project_path, status, created_at, updated_at, merged_into
|
|
36
|
+
FROM sessions
|
|
37
|
+
WHERE session_id = ?`,
|
|
38
|
+
)
|
|
39
|
+
.get(sessionId) ?? null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildBodySet(rows) {
|
|
46
|
+
return new Set(rows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function distinctOriginSessionIds(...rowGroups) {
|
|
50
|
+
const ids = new Set();
|
|
51
|
+
for (const rows of rowGroups) {
|
|
52
|
+
for (const r of rows) {
|
|
53
|
+
if (r.origin_session_id) ids.add(r.origin_session_id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...ids].sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inferSourceAdapter(sessionId, originSessionIds) {
|
|
60
|
+
const ids = [sessionId, ...originSessionIds].filter(Boolean);
|
|
61
|
+
if (ids.length > 0 && ids.every((id) => String(id).startsWith(CODEX_SESSION_PREFIX))) {
|
|
62
|
+
return 'codex';
|
|
63
|
+
}
|
|
64
|
+
return 'claude';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadBodies(db, { sessionId, excludeOriginId, recentTurnLimit }) {
|
|
68
|
+
const hasExclude = Boolean(excludeOriginId);
|
|
69
|
+
const bodiesQuery = hasExclude
|
|
70
|
+
? `SELECT origin_session_id, turn_number, role, text, created_at
|
|
71
|
+
FROM bodies
|
|
72
|
+
WHERE session_id = ? AND origin_session_id != ?
|
|
73
|
+
ORDER BY created_at DESC`
|
|
74
|
+
: `SELECT origin_session_id, turn_number, role, text, created_at
|
|
75
|
+
FROM bodies
|
|
76
|
+
WHERE session_id = ?
|
|
77
|
+
ORDER BY created_at DESC`;
|
|
78
|
+
|
|
79
|
+
let desc = [];
|
|
80
|
+
try {
|
|
81
|
+
desc = hasExclude
|
|
82
|
+
? db.prepare(bodiesQuery).all(sessionId, excludeOriginId)
|
|
83
|
+
: db.prepare(bodiesQuery).all(sessionId);
|
|
84
|
+
} catch {
|
|
85
|
+
desc = [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const selectedTurns = new Set();
|
|
89
|
+
const selectedRows = [];
|
|
90
|
+
for (const row of desc) {
|
|
91
|
+
const key = `${row.origin_session_id}\x00${row.turn_number}`;
|
|
92
|
+
if (!selectedTurns.has(key)) {
|
|
93
|
+
if (selectedTurns.size >= recentTurnLimit) continue;
|
|
94
|
+
selectedTurns.add(key);
|
|
95
|
+
}
|
|
96
|
+
selectedRows.push(row);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return selectedRows.reverse();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function loadL1Summaries(db, { sessionId, excludeOriginId, bodyRows }) {
|
|
103
|
+
const hasExclude = Boolean(excludeOriginId);
|
|
104
|
+
const skelQuery = hasExclude
|
|
105
|
+
? `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
106
|
+
FROM skeletons
|
|
107
|
+
WHERE session_id = ? AND origin_session_id != ?
|
|
108
|
+
ORDER BY created_at ASC`
|
|
109
|
+
: `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
110
|
+
FROM skeletons
|
|
111
|
+
WHERE session_id = ?
|
|
112
|
+
ORDER BY created_at ASC`;
|
|
113
|
+
|
|
114
|
+
let all = [];
|
|
115
|
+
try {
|
|
116
|
+
all = hasExclude
|
|
117
|
+
? db.prepare(skelQuery).all(sessionId, excludeOriginId)
|
|
118
|
+
: db.prepare(skelQuery).all(sessionId);
|
|
119
|
+
} catch {
|
|
120
|
+
all = [];
|
|
121
|
+
}
|
|
122
|
+
const bodySet = buildBodySet(bodyRows);
|
|
123
|
+
return all.filter((s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadLatestThinking(db, { sessionId, excludeOriginId }) {
|
|
127
|
+
const hasExclude = Boolean(excludeOriginId);
|
|
128
|
+
const latestQuery = hasExclude
|
|
129
|
+
? `SELECT origin_session_id, turn_number, created_at
|
|
130
|
+
FROM bodies
|
|
131
|
+
WHERE session_id = ? AND origin_session_id != ? AND role = 'assistant'
|
|
132
|
+
ORDER BY created_at DESC
|
|
133
|
+
LIMIT 1`
|
|
134
|
+
: `SELECT origin_session_id, turn_number, created_at
|
|
135
|
+
FROM bodies
|
|
136
|
+
WHERE session_id = ? AND role = 'assistant'
|
|
137
|
+
ORDER BY created_at DESC
|
|
138
|
+
LIMIT 1`;
|
|
139
|
+
|
|
140
|
+
let latest;
|
|
141
|
+
try {
|
|
142
|
+
latest = hasExclude
|
|
143
|
+
? db.prepare(latestQuery).get(sessionId, excludeOriginId)
|
|
144
|
+
: db.prepare(latestQuery).get(sessionId);
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
if (!latest) return [];
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return db
|
|
152
|
+
.prepare(
|
|
153
|
+
`SELECT origin_session_id, turn_number, output_text, created_at, source_id
|
|
154
|
+
FROM details
|
|
155
|
+
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND kind = 'thinking'
|
|
156
|
+
ORDER BY created_at ASC`,
|
|
157
|
+
)
|
|
158
|
+
.all(sessionId, latest.origin_session_id, latest.turn_number)
|
|
159
|
+
.filter((r) => typeof r.output_text === 'string' && r.output_text.length > 0);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function loadL3References(db, { sessionId, bodyRows }) {
|
|
166
|
+
const turnKeys = [...buildBodySet(bodyRows)];
|
|
167
|
+
if (turnKeys.length === 0) return [];
|
|
168
|
+
|
|
169
|
+
const tuples = turnKeys.map((k) => k.split('\x00'));
|
|
170
|
+
const placeholders = tuples.map(() => '(?, ?, ?)').join(', ');
|
|
171
|
+
const params = tuples.flatMap(([origin, turn]) => [sessionId, origin, Number(turn)]);
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
return db
|
|
175
|
+
.prepare(
|
|
176
|
+
`SELECT kind, tool_name, source_id, origin_session_id, turn_number, created_at
|
|
177
|
+
FROM details
|
|
178
|
+
WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
|
|
179
|
+
ORDER BY created_at ASC, id ASC`,
|
|
180
|
+
)
|
|
181
|
+
.all(...params)
|
|
182
|
+
.map((r) => ({
|
|
183
|
+
kind: r.kind,
|
|
184
|
+
toolName: r.tool_name,
|
|
185
|
+
sourceId: r.source_id ?? null,
|
|
186
|
+
originSessionId: r.origin_session_id,
|
|
187
|
+
turnNumber: r.turn_number,
|
|
188
|
+
createdAt: r.created_at,
|
|
189
|
+
detailCommand: `throughline detail ${formatTime(r.created_at)}`,
|
|
190
|
+
}));
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
198
|
+
* @param {{
|
|
199
|
+
* sessionId: string,
|
|
200
|
+
* isInheritance?: boolean,
|
|
201
|
+
* excludeOriginId?: string | null,
|
|
202
|
+
* inflightMemo?: string | null,
|
|
203
|
+
* intent?: string,
|
|
204
|
+
* constraints?: string[],
|
|
205
|
+
* recentTurnLimit?: number,
|
|
206
|
+
* }} params
|
|
207
|
+
*/
|
|
208
|
+
export function buildHandoffRecord(
|
|
209
|
+
db,
|
|
210
|
+
{
|
|
211
|
+
sessionId,
|
|
212
|
+
isInheritance = false,
|
|
213
|
+
excludeOriginId = null,
|
|
214
|
+
inflightMemo = null,
|
|
215
|
+
intent = DEFAULT_INTENT,
|
|
216
|
+
constraints = DEFAULT_CONSTRAINTS,
|
|
217
|
+
recentTurnLimit = N_RECENT_L2,
|
|
218
|
+
},
|
|
219
|
+
) {
|
|
220
|
+
if (!sessionId) return null;
|
|
221
|
+
|
|
222
|
+
const session = loadSession(db, sessionId);
|
|
223
|
+
const bodyRows = loadBodies(db, { sessionId, excludeOriginId, recentTurnLimit });
|
|
224
|
+
const l1Rows = loadL1Summaries(db, { sessionId, excludeOriginId, bodyRows });
|
|
225
|
+
const thinkingRows = loadLatestThinking(db, { sessionId, excludeOriginId });
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
bodyRows.length === 0 &&
|
|
229
|
+
l1Rows.length === 0 &&
|
|
230
|
+
thinkingRows.length === 0 &&
|
|
231
|
+
!inflightMemo
|
|
232
|
+
) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const l3References = loadL3References(db, { sessionId, bodyRows });
|
|
237
|
+
const originSessionIds = distinctOriginSessionIds(bodyRows, l1Rows, thinkingRows);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
kind: 'handoff_record',
|
|
241
|
+
version: HANDOFF_RECORD_VERSION,
|
|
242
|
+
session: {
|
|
243
|
+
id: sessionId,
|
|
244
|
+
projectPath: session?.project_path ?? null,
|
|
245
|
+
status: session?.status ?? null,
|
|
246
|
+
mergedInto: session?.merged_into ?? null,
|
|
247
|
+
},
|
|
248
|
+
source: {
|
|
249
|
+
adapter: inferSourceAdapter(sessionId, originSessionIds),
|
|
250
|
+
inheritance: Boolean(isInheritance),
|
|
251
|
+
excludeOriginId: excludeOriginId ?? null,
|
|
252
|
+
originSessionIds,
|
|
253
|
+
},
|
|
254
|
+
intent,
|
|
255
|
+
constraints: [...constraints],
|
|
256
|
+
memory: {
|
|
257
|
+
inflightMemo: inflightMemo && inflightMemo.trim().length > 0 ? inflightMemo.trim() : null,
|
|
258
|
+
latestThinking: thinkingRows.map((r) => ({
|
|
259
|
+
originSessionId: r.origin_session_id,
|
|
260
|
+
turnNumber: r.turn_number,
|
|
261
|
+
text: r.output_text,
|
|
262
|
+
createdAt: r.created_at,
|
|
263
|
+
time: formatTime(r.created_at),
|
|
264
|
+
sourceId: r.source_id ?? null,
|
|
265
|
+
})),
|
|
266
|
+
l1Summaries: l1Rows.map((r) => ({
|
|
267
|
+
originSessionId: r.origin_session_id,
|
|
268
|
+
turnNumber: r.turn_number,
|
|
269
|
+
role: r.role,
|
|
270
|
+
summary: r.summary,
|
|
271
|
+
createdAt: r.created_at,
|
|
272
|
+
time: formatTime(r.created_at),
|
|
273
|
+
})),
|
|
274
|
+
recentBodies: bodyRows.map((r) => ({
|
|
275
|
+
originSessionId: r.origin_session_id,
|
|
276
|
+
turnNumber: r.turn_number,
|
|
277
|
+
role: r.role,
|
|
278
|
+
text: r.text,
|
|
279
|
+
createdAt: r.created_at,
|
|
280
|
+
time: formatTime(r.created_at),
|
|
281
|
+
})),
|
|
282
|
+
},
|
|
283
|
+
references: {
|
|
284
|
+
l3: l3References,
|
|
285
|
+
},
|
|
286
|
+
stats: {
|
|
287
|
+
l1Rows: l1Rows.length,
|
|
288
|
+
l2Rows: bodyRows.length,
|
|
289
|
+
thinkingRows: thinkingRows.length,
|
|
290
|
+
l3References: l3References.length,
|
|
291
|
+
preservedContextRows: bodyRows.length + l1Rows.length,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|