throughline 0.4.12 → 0.5.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.
@@ -30,11 +30,33 @@ import { consumeBaton } from './baton.mjs';
30
30
  import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
31
31
  import { buildResumeContext } from './resume-context.mjs';
32
32
  import { ensureMonitorTaskFile } from './vscode-task.mjs';
33
- import { appendFileSync, mkdirSync } from 'node:fs';
33
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
34
+ import { randomBytes } from 'node:crypto';
34
35
  import { join, dirname } from 'node:path';
35
36
  import { homedir } from 'node:os';
36
37
  import { pathToFileURL } from 'node:url';
37
38
 
39
+ // SPIKE ONLY — Phase 0-2 / 0-4 検証用。marker file 削除で無効化される。
40
+ // docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md §3 Phase 0-2 参照。
41
+ const SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-inject.flag');
42
+
43
+ // Phase 0-6: initialUserMessage が interactive モードで効くか実機検証する experimental switch。
44
+ // flag 存在時、SessionStart hook は plain stdout の代わりに JSON 出力に切り替わり、
45
+ // hookSpecificOutput.initialUserMessage に tracer 入りメッセージを乗せる。
46
+ // openclaude の OSS 実装では「headless 専用」と記載されているが、real CC の挙動は未確認。
47
+ const INITIAL_USER_MESSAGE_TEST_FLAG = join(homedir(), '.throughline', 'initial-user-message-test.flag');
48
+
49
+ function logInitialUserMessageTest(entry) {
50
+ const path = join(homedir(), '.throughline', 'logs', 'initial-user-message-test.log');
51
+ try {
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
54
+ } catch (err) {
55
+ const msg = err instanceof Error ? err.message : 'unknown';
56
+ process.stderr.write(`[session-start:initialUserMessage-test-log] ${msg}\n`);
57
+ }
58
+ }
59
+
38
60
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
39
61
 
40
62
  function isAutoHandoffDisabled(env) {
@@ -88,7 +110,7 @@ export async function run() {
88
110
  });
89
111
 
90
112
  const payload = JSON.parse(raw);
91
- const { session_id, cwd, source } = payload;
113
+ const { session_id, cwd, source, transcript_path } = payload;
92
114
 
93
115
  if (!session_id) throw new Error('Missing session_id in SessionStart payload');
94
116
 
@@ -146,11 +168,80 @@ export async function run() {
146
168
  mergeResult = { merged: false, skipReason: 'auto_handoff_disabled' };
147
169
  }
148
170
 
171
+ // 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
172
+ // 順序厳守: stdout flush を先に完了させてから spike 分岐へ進む。
173
+ // spike が throw しても stdout は既に attachment に保存されている。
174
+ //
175
+ // Phase 0-6: initialUserMessage test flag 存在時は JSON 出力に切り替え、
176
+ // initialUserMessage が interactive モードで messages[] に乗るか実機検証する。
177
+ if (mergeResult.merged) {
178
+ const text = buildResumeContext(db, {
179
+ sessionId: session_id,
180
+ isInheritance: true,
181
+ });
182
+ if (existsSync(INITIAL_USER_MESSAGE_TEST_FLAG)) {
183
+ // TEST MODE: emit JSON with initialUserMessage tracer. Plain stdout は出さない
184
+ // (= 通常 Throughline 案内文無し)。テスト 1 回限定。flag を削除すれば即復帰。
185
+ const tracer = randomBytes(4).toString('hex');
186
+ const initialMessage =
187
+ `[initial-user-tracer: ${tracer}]\n\n` +
188
+ `This text is being delivered via the SessionStart hook's ` +
189
+ `hookSpecificOutput.initialUserMessage field. If you can quote the 8-hex ` +
190
+ `tracer above when asked, it means initialUserMessage IS consumed in ` +
191
+ `interactive mode (not headless-only as openclaude documents).`;
192
+ const jsonOutput = JSON.stringify({
193
+ hookSpecificOutput: {
194
+ hookEventName: 'SessionStart',
195
+ initialUserMessage: initialMessage,
196
+ },
197
+ });
198
+ process.stdout.write(jsonOutput + '\n');
199
+ logInitialUserMessageTest({
200
+ ts: new Date(now).toISOString(),
201
+ session_id,
202
+ tracer,
203
+ mode: 'json-initial-user-message-only',
204
+ had_resume_context: Boolean(text),
205
+ });
206
+ } else if (text) {
207
+ process.stdout.write(text + '\n');
208
+ }
209
+ }
210
+
211
+ // 5. SPIKE: marker file あり + merge 成立 + transcript_path あり の 3 条件で
212
+ // L2 を user/assistant role 付きで transcript_path にも append する。
213
+ // 本実装ではない (docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-2)。
214
+ //
215
+ // tracer: 末尾 assistant 行に stdout 注入には含まれない一意トークンを付与する。
216
+ // 次の /clear 後に Claude が tracer を再現できれば JSONL 経路はモデル可視。
217
+ let spikeResult = null;
218
+ const spikeMarkerExists = existsSync(SPIKE_MARKER_PATH);
219
+ if (spikeMarkerExists && mergeResult.merged && transcript_path) {
220
+ try {
221
+ const { spikeInject, generateSpikeTracer } = await import('./spike-transcript-writer.mjs');
222
+ const tracer = generateSpikeTracer();
223
+ spikeResult = spikeInject({
224
+ db,
225
+ targetJsonlPath: transcript_path,
226
+ newSessionId: session_id,
227
+ cwd: projectPath,
228
+ version: payload.version ?? '2.1.145',
229
+ gitBranch: payload.gitBranch ?? 'main',
230
+ tracer,
231
+ });
232
+ } catch (err) {
233
+ const msg = err instanceof Error ? err.message : 'unknown';
234
+ process.stderr.write(`[spike-inject] ${msg}\n`);
235
+ spikeResult = { error: msg };
236
+ }
237
+ }
238
+
149
239
  logDecision({
150
240
  ts: new Date(now).toISOString(),
151
241
  source: source ?? null,
152
242
  session_id,
153
243
  project_path: projectPath,
244
+ transcript_path: transcript_path ?? null,
154
245
  triggered_path: triggeredPath,
155
246
  auto_handoff_disabled: autoDisabled,
156
247
  baton_session_id: baton.sessionId ?? null,
@@ -159,19 +250,10 @@ export async function run() {
159
250
  merged: mergeResult.merged,
160
251
  merge_skip_reason: mergeResult.skipReason ?? null,
161
252
  predecessor_id: mergeResult.predecessorId ?? null,
253
+ spike_marker_exists: spikeMarkerExists,
254
+ spike_result: spikeResult,
162
255
  });
163
256
 
164
- // 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
165
- if (mergeResult.merged) {
166
- const text = buildResumeContext(db, {
167
- sessionId: session_id,
168
- isInheritance: true,
169
- });
170
- if (text) {
171
- process.stdout.write(text + '\n');
172
- }
173
- }
174
-
175
257
  process.exit(0);
176
258
  }
177
259
 
@@ -0,0 +1,196 @@
1
+ /**
2
+ * SPIKE ONLY — Phase 0-2 / 0-4 検証用。本実装ではない。
3
+ *
4
+ * docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-2 で
5
+ * 「`/clear` 直後の SessionStart hook 内で transcript_path に L2 を user/assistant
6
+ * role 付きで append すると、Claude が次の short prompt の文脈として読むか」を実機検証する。
7
+ *
8
+ * 本実装ではない理由:
9
+ * - text content のみ復元 (tool_use / tool_result / thinking は割愛)
10
+ * - idempotency 簡易チェックのみ
11
+ * - 本実装 (Phase 1-1) では src/transcript-writer.mjs を別途作る
12
+ *
13
+ * tracer 経路: 注入した JSONL 行が**モデルの message 履歴に乗ったか**を切り分けるため、
14
+ * 最終 assistant 行末尾に **stdout 注入には含まれない一意トークン** を付与する。
15
+ * 次の /clear 後にユーザーがその合言葉の再現を求め、Claude が答えられれば JSONL 経路は
16
+ * モデル可視。答えられなければ JSONL は保持されてもメッセージ履歴に乗らない (孤立 chain
17
+ * 等が原因)。
18
+ *
19
+ * marker file `~/.throughline/spike-inject.flag` 削除で spike は無効化される。
20
+ */
21
+
22
+ import { readFileSync, existsSync, fsyncSync, openSync, closeSync, writeSync } from 'node:fs';
23
+ import { randomUUID, randomBytes } from 'node:crypto';
24
+ import { buildHandoffRecord } from './handoff-record.mjs';
25
+
26
+ /**
27
+ * targetJsonl の末尾行の uuid を返す。chain 設計案 (b) の親決定用。
28
+ * file が無い / 空 / uuid 持ち行が無い場合は null を返す。
29
+ */
30
+ function readLastUuid(path) {
31
+ if (!existsSync(path)) return null;
32
+ const text = readFileSync(path, 'utf8');
33
+ const lines = text.split('\n').filter((l) => l.trim());
34
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
35
+ try {
36
+ const o = JSON.parse(lines[i]);
37
+ if (typeof o.uuid === 'string') return o.uuid;
38
+ } catch {
39
+ // skip non-json line
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function buildUserLine({ b, parentUuid, newSessionId, cwd, version, gitBranch }) {
46
+ const uuid = randomUUID();
47
+ const ts = new Date(b.createdAt ?? Date.now()).toISOString();
48
+ const obj = {
49
+ parentUuid,
50
+ isSidechain: false,
51
+ promptId: randomUUID(),
52
+ type: 'user',
53
+ message: {
54
+ role: 'user',
55
+ content: [{ type: 'text', text: b.text ?? '' }],
56
+ },
57
+ uuid,
58
+ timestamp: ts,
59
+ permissionMode: 'auto',
60
+ userType: 'external',
61
+ entrypoint: 'claude-vscode',
62
+ cwd,
63
+ sessionId: newSessionId,
64
+ version,
65
+ gitBranch,
66
+ };
67
+ return { uuid, line: JSON.stringify(obj) };
68
+ }
69
+
70
+ function buildAssistantLine({ b, parentUuid, newSessionId, cwd, version, gitBranch, tracer, assistantModel }) {
71
+ const uuid = randomUUID();
72
+ const ts = new Date(b.createdAt ?? Date.now()).toISOString();
73
+ const baseText = b.text ?? '';
74
+ const text = tracer ? `${baseText}\n\n[spike-tracer: ${tracer}]` : baseText;
75
+ const obj = {
76
+ parentUuid,
77
+ isSidechain: false,
78
+ message: {
79
+ model: assistantModel,
80
+ id: `msg_spike_${uuid.slice(0, 8)}`,
81
+ type: 'message',
82
+ role: 'assistant',
83
+ content: [{ type: 'text', text }],
84
+ stop_reason: 'end_turn',
85
+ stop_sequence: null,
86
+ usage: { input_tokens: 0, output_tokens: 0 },
87
+ },
88
+ requestId: `req_spike_${uuid.slice(0, 8)}`,
89
+ type: 'assistant',
90
+ uuid,
91
+ timestamp: ts,
92
+ userType: 'external',
93
+ entrypoint: 'claude-vscode',
94
+ cwd,
95
+ sessionId: newSessionId,
96
+ version,
97
+ gitBranch,
98
+ };
99
+ return { uuid, line: JSON.stringify(obj) };
100
+ }
101
+
102
+ /**
103
+ * 末尾 assistant 行に埋める tracer を生成する。8 hex (32 bit)。
104
+ * stdout 注入には含まれない値である必要があるため、DB body text とは無相関に乱数生成する。
105
+ */
106
+ export function generateSpikeTracer() {
107
+ return randomBytes(4).toString('hex');
108
+ }
109
+
110
+ /**
111
+ * spike append. fsync 付きで JSONL に user/assistant 行を append する。
112
+ *
113
+ * @param {object} opts
114
+ * @param {string|null} [opts.tracer] 末尾 assistant 行に付与する一意トークン。
115
+ * 未指定なら付与しない (back-compat: 既存呼び出し用)。
116
+ * @returns {{
117
+ * appended: number,
118
+ * parentUuidStart: string|null,
119
+ * tracer: string|null,
120
+ * tracerAppendedAt: number|null,
121
+ * skipReason?: string
122
+ * }}
123
+ */
124
+ // Phase 0-5 retry: 偽モデル名 ('claude-throughline-spike') では Claude Code が messages[]
125
+ // 構築時にフィルタしている可能性があるため、デフォルトは実在 Claude モデル名にする。
126
+ const DEFAULT_SPIKE_ASSISTANT_MODEL = 'claude-opus-4-7';
127
+
128
+ export function spikeInject({
129
+ db,
130
+ targetJsonlPath,
131
+ newSessionId,
132
+ cwd,
133
+ version,
134
+ gitBranch,
135
+ tracer = null,
136
+ assistantModel = DEFAULT_SPIKE_ASSISTANT_MODEL,
137
+ }) {
138
+ const record = buildHandoffRecord(db, { sessionId: newSessionId, isInheritance: true });
139
+ if (!record || !record.memory?.recentBodies?.length) {
140
+ return {
141
+ appended: 0,
142
+ parentUuidStart: null,
143
+ tracer: null,
144
+ tracerAppendedAt: null,
145
+ skipReason: 'no_record_or_empty_l2',
146
+ };
147
+ }
148
+ const bodies = record.memory.recentBodies; // 古い順
149
+
150
+ // 末尾 assistant 行を 1 件特定 (assistant が必ず末尾とは限らないため後ろから探す)。
151
+ let lastAssistantIdx = -1;
152
+ for (let i = bodies.length - 1; i >= 0; i -= 1) {
153
+ if (bodies[i].role === 'assistant') {
154
+ lastAssistantIdx = i;
155
+ break;
156
+ }
157
+ }
158
+
159
+ let parentUuid = readLastUuid(targetJsonlPath);
160
+ const parentUuidStart = parentUuid;
161
+ const lines = [];
162
+ let tracerAppendedAt = null;
163
+ for (let i = 0; i < bodies.length; i += 1) {
164
+ const b = bodies[i];
165
+ const isLastAssistant = Boolean(tracer) && i === lastAssistantIdx;
166
+ const built = b.role === 'user'
167
+ ? buildUserLine({ b, parentUuid, newSessionId, cwd, version, gitBranch })
168
+ : buildAssistantLine({
169
+ b,
170
+ parentUuid,
171
+ newSessionId,
172
+ cwd,
173
+ version,
174
+ gitBranch,
175
+ tracer: isLastAssistant ? tracer : null,
176
+ assistantModel,
177
+ });
178
+ if (isLastAssistant) tracerAppendedAt = i;
179
+ lines.push(built.line);
180
+ parentUuid = built.uuid;
181
+ }
182
+ // sync write + fsync で hook 終了前に確実に flush
183
+ const fd = openSync(targetJsonlPath, 'a');
184
+ try {
185
+ writeSync(fd, lines.join('\n') + '\n');
186
+ fsyncSync(fd);
187
+ } finally {
188
+ closeSync(fd);
189
+ }
190
+ return {
191
+ appended: lines.length,
192
+ parentUuidStart,
193
+ tracer: tracer ?? null,
194
+ tracerAppendedAt,
195
+ };
196
+ }
@@ -0,0 +1,298 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { spikeInject, generateSpikeTracer } from './spike-transcript-writer.mjs';
8
+
9
+ function makeDb() {
10
+ const db = new DatabaseSync(':memory:');
11
+ db.exec(`
12
+ CREATE TABLE sessions (
13
+ session_id TEXT PRIMARY KEY,
14
+ project_path TEXT NOT NULL,
15
+ status TEXT NOT NULL DEFAULT 'active',
16
+ created_at INTEGER NOT NULL,
17
+ updated_at INTEGER NOT NULL,
18
+ merged_into TEXT
19
+ );
20
+ CREATE TABLE skeletons (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ session_id TEXT NOT NULL,
23
+ origin_session_id TEXT,
24
+ turn_number INTEGER NOT NULL,
25
+ role TEXT NOT NULL,
26
+ summary TEXT NOT NULL,
27
+ created_at INTEGER NOT NULL
28
+ );
29
+ CREATE TABLE bodies (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ session_id TEXT NOT NULL,
32
+ origin_session_id TEXT NOT NULL,
33
+ turn_number INTEGER NOT NULL,
34
+ role TEXT NOT NULL,
35
+ text TEXT NOT NULL,
36
+ token_count INTEGER,
37
+ created_at INTEGER NOT NULL
38
+ );
39
+ CREATE TABLE details (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ session_id TEXT NOT NULL,
42
+ origin_session_id TEXT,
43
+ turn_number INTEGER,
44
+ tool_name TEXT NOT NULL,
45
+ input_text TEXT,
46
+ output_text TEXT,
47
+ token_count INTEGER NOT NULL DEFAULT 0,
48
+ created_at INTEGER NOT NULL,
49
+ kind TEXT,
50
+ source_id TEXT
51
+ );
52
+ `);
53
+ return db;
54
+ }
55
+
56
+ function seedSession(db, { newSessionId = 'NEW', originId = 'ORIG' } = {}) {
57
+ db.prepare(
58
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
59
+ VALUES (?, '/repo', 'active', 1, 2)`,
60
+ ).run(newSessionId);
61
+ // 2 user / 2 assistant turn を新 session 配下に origin_session_id=ORIG で配置
62
+ // (handoff-record は inheritance mode で origin != current を recent bodies に拾う)
63
+ const rows = [
64
+ { turn: 1, role: 'user', text: 'こんにちは。tracer 検証だ', createdAt: 1000 },
65
+ { turn: 2, role: 'assistant', text: '了解です。テスト用ターン 1', createdAt: 1100 },
66
+ { turn: 3, role: 'user', text: '続きを書け', createdAt: 1200 },
67
+ { turn: 4, role: 'assistant', text: '最後のアシスタント発話 (tracer 受け取り側)', createdAt: 1300 },
68
+ ];
69
+ for (const r of rows) {
70
+ db.prepare(
71
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
72
+ VALUES (?, ?, ?, ?, ?, 1, ?)`,
73
+ ).run(newSessionId, originId, r.turn, r.role, r.text, r.createdAt);
74
+ }
75
+ return rows;
76
+ }
77
+
78
+ function makeTempJsonl() {
79
+ const dir = mkdtempSync(join(tmpdir(), 'spike-writer-test-'));
80
+ return join(dir, 'session.jsonl');
81
+ }
82
+
83
+ test('generateSpikeTracer returns 8 hex chars', () => {
84
+ const t = generateSpikeTracer();
85
+ assert.match(t, /^[0-9a-f]{8}$/);
86
+ assert.notEqual(t, generateSpikeTracer()); // 衝突しない (実質)
87
+ });
88
+
89
+ test('spikeInject: returns skipReason when no recentBodies', () => {
90
+ const db = makeDb();
91
+ db.prepare(
92
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
93
+ VALUES ('NEW', '/repo', 'active', 1, 2)`,
94
+ ).run();
95
+ const target = makeTempJsonl();
96
+ const result = spikeInject({
97
+ db,
98
+ targetJsonlPath: target,
99
+ newSessionId: 'NEW',
100
+ cwd: '/repo',
101
+ version: '2.1.x',
102
+ gitBranch: 'main',
103
+ tracer: 'abcd1234',
104
+ });
105
+ assert.equal(result.appended, 0);
106
+ assert.equal(result.skipReason, 'no_record_or_empty_l2');
107
+ assert.equal(result.tracer, null);
108
+ assert.equal(result.tracerAppendedAt, null);
109
+ });
110
+
111
+ test('spikeInject: appends bodies, embeds tracer in last assistant only', () => {
112
+ const db = makeDb();
113
+ seedSession(db);
114
+ const target = makeTempJsonl();
115
+ const tracer = 'deadbeef';
116
+
117
+ const result = spikeInject({
118
+ db,
119
+ targetJsonlPath: target,
120
+ newSessionId: 'NEW',
121
+ cwd: '/repo',
122
+ version: '2.1.x',
123
+ gitBranch: 'main',
124
+ tracer,
125
+ });
126
+ assert.equal(result.appended, 4);
127
+ assert.equal(result.parentUuidStart, null); // empty file 起点
128
+ assert.equal(result.tracer, tracer);
129
+ assert.equal(result.tracerAppendedAt, 3); // index of last assistant
130
+
131
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
132
+ assert.equal(lines.length, 4);
133
+
134
+ const parsed = lines.map((l) => JSON.parse(l));
135
+ // 順序 (古い順): user → assistant → user → assistant
136
+ assert.deepEqual(parsed.map((o) => o.type), ['user', 'assistant', 'user', 'assistant']);
137
+ // tracer は最後の assistant のみ
138
+ const lastAssistant = parsed[3];
139
+ assert.match(lastAssistant.message.content[0].text, /\[spike-tracer: deadbeef\]$/);
140
+ // 中間の assistant には tracer が無い
141
+ const midAssistant = parsed[1];
142
+ assert.doesNotMatch(midAssistant.message.content[0].text, /spike-tracer/);
143
+ // user 行にも無い
144
+ for (const u of [parsed[0], parsed[2]]) {
145
+ assert.doesNotMatch(u.message.content[0].text, /spike-tracer/);
146
+ }
147
+ });
148
+
149
+ test('spikeInject: omits tracer entirely when tracer=null', () => {
150
+ const db = makeDb();
151
+ seedSession(db);
152
+ const target = makeTempJsonl();
153
+
154
+ const result = spikeInject({
155
+ db,
156
+ targetJsonlPath: target,
157
+ newSessionId: 'NEW',
158
+ cwd: '/repo',
159
+ version: '2.1.x',
160
+ gitBranch: 'main',
161
+ tracer: null,
162
+ });
163
+ assert.equal(result.appended, 4);
164
+ assert.equal(result.tracer, null);
165
+ assert.equal(result.tracerAppendedAt, null);
166
+
167
+ const content = readFileSync(target, 'utf8');
168
+ assert.doesNotMatch(content, /spike-tracer/);
169
+ });
170
+
171
+ test('spikeInject: chains from last uuid when target has preexisting lines', () => {
172
+ const db = makeDb();
173
+ seedSession(db);
174
+ const target = makeTempJsonl();
175
+ const preexisting = { type: 'attachment', uuid: 'pre-attachment-uuid', parentUuid: null };
176
+ writeFileSync(target, JSON.stringify(preexisting) + '\n', 'utf8');
177
+
178
+ const result = spikeInject({
179
+ db,
180
+ targetJsonlPath: target,
181
+ newSessionId: 'NEW',
182
+ cwd: '/repo',
183
+ version: '2.1.x',
184
+ gitBranch: 'main',
185
+ tracer: 'cafef00d',
186
+ });
187
+ assert.equal(result.parentUuidStart, 'pre-attachment-uuid');
188
+
189
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
190
+ // 1 件目 (preexisting) は preserve、2 件目が最初の spike user
191
+ const firstSpike = JSON.parse(lines[1]);
192
+ assert.equal(firstSpike.parentUuid, 'pre-attachment-uuid');
193
+ // chain は spike 内で繋がる
194
+ const secondSpike = JSON.parse(lines[2]);
195
+ assert.equal(secondSpike.parentUuid, firstSpike.uuid);
196
+ });
197
+
198
+ test('spikeInject: defaults assistant model to a real Claude model name', () => {
199
+ const db = makeDb();
200
+ seedSession(db);
201
+ const target = makeTempJsonl();
202
+ spikeInject({
203
+ db,
204
+ targetJsonlPath: target,
205
+ newSessionId: 'NEW',
206
+ cwd: '/repo',
207
+ version: '2.1.x',
208
+ gitBranch: 'main',
209
+ tracer: 'aabbccdd',
210
+ });
211
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
212
+ const firstAssistant = JSON.parse(lines[1]); // index 1 = first assistant in [user, assistant, user, assistant]
213
+ assert.equal(firstAssistant.message.model, 'claude-opus-4-7', 'Phase 0-5 retry: real model name not fake');
214
+ });
215
+
216
+ test('spikeInject: assistantModel override is honored', () => {
217
+ const db = makeDb();
218
+ seedSession(db);
219
+ const target = makeTempJsonl();
220
+ spikeInject({
221
+ db,
222
+ targetJsonlPath: target,
223
+ newSessionId: 'NEW',
224
+ cwd: '/repo',
225
+ version: '2.1.x',
226
+ gitBranch: 'main',
227
+ tracer: '11223344',
228
+ assistantModel: 'claude-overridden-model',
229
+ });
230
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
231
+ const firstAssistant = JSON.parse(lines[1]);
232
+ assert.equal(firstAssistant.message.model, 'claude-overridden-model');
233
+ });
234
+
235
+ test('spikeInject: writes Claude Code-compatible shape (top-level fields)', () => {
236
+ const db = makeDb();
237
+ seedSession(db, { newSessionId: 'NEW-SESSION' });
238
+ const target = makeTempJsonl();
239
+
240
+ spikeInject({
241
+ db,
242
+ targetJsonlPath: target,
243
+ newSessionId: 'NEW-SESSION',
244
+ cwd: '/repo',
245
+ version: '2.1.145',
246
+ gitBranch: 'main',
247
+ tracer: '00000000',
248
+ });
249
+
250
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
251
+ const u = JSON.parse(lines[0]);
252
+ // user turn の必須 14 フィールド (Plan §6 リバース結果より)
253
+ for (const k of [
254
+ 'parentUuid',
255
+ 'isSidechain',
256
+ 'promptId',
257
+ 'type',
258
+ 'message',
259
+ 'uuid',
260
+ 'timestamp',
261
+ 'permissionMode',
262
+ 'userType',
263
+ 'entrypoint',
264
+ 'cwd',
265
+ 'sessionId',
266
+ 'version',
267
+ 'gitBranch',
268
+ ]) {
269
+ assert.ok(k in u, `user line missing field: ${k}`);
270
+ }
271
+ assert.equal(u.type, 'user');
272
+ assert.equal(u.sessionId, 'NEW-SESSION');
273
+ assert.equal(u.version, '2.1.145');
274
+ assert.equal(u.message.role, 'user');
275
+
276
+ const a = JSON.parse(lines[1]);
277
+ // assistant turn の必須 13 フィールド
278
+ for (const k of [
279
+ 'parentUuid',
280
+ 'isSidechain',
281
+ 'message',
282
+ 'requestId',
283
+ 'type',
284
+ 'uuid',
285
+ 'timestamp',
286
+ 'userType',
287
+ 'entrypoint',
288
+ 'cwd',
289
+ 'sessionId',
290
+ 'version',
291
+ 'gitBranch',
292
+ ]) {
293
+ assert.ok(k in a, `assistant line missing field: ${k}`);
294
+ }
295
+ assert.equal(a.type, 'assistant');
296
+ assert.equal(a.message.role, 'assistant');
297
+ assert.ok(a.message.usage);
298
+ });