throughline 0.9.0 → 0.10.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.
@@ -0,0 +1,71 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ // Grok includes native injections only when synthetic_reason is
5
+ // system_reminder. A custom reason is written to disk and dropped from
6
+ // the model prompt (live session 01a00cf9).
7
+ export const GROK_HANDOFF_SYNTHETIC_REASON = 'system_reminder';
8
+ export const GROK_HANDOFF_MARKER = 'data-throughline-handoff="1"';
9
+
10
+ function rowText(entry) {
11
+ if (typeof entry?.content === 'string') return entry.content;
12
+ if (Array.isArray(entry?.content)) {
13
+ return entry.content
14
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
15
+ .map((block) => block.text)
16
+ .join('');
17
+ }
18
+ return '';
19
+ }
20
+
21
+ function parseLines(raw) {
22
+ const rows = [];
23
+ for (const line of raw.split('\n')) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed) continue;
26
+ try {
27
+ rows.push(JSON.parse(trimmed));
28
+ } catch {
29
+ rows.push({ type: 'unparsed', content: line });
30
+ }
31
+ }
32
+ return rows;
33
+ }
34
+
35
+ /**
36
+ * Insert Throughline resume text as a Grok synthetic user row immediately
37
+ * before the latest <user_query>. Grok ignores UserPromptSubmit stdout.
38
+ */
39
+ export function injectGrokHandoffContext(transcriptPath, injectionText) {
40
+ if (!transcriptPath || typeof injectionText !== 'string' || injectionText.length === 0) {
41
+ return { injected: false, reason: 'missing_path_or_text' };
42
+ }
43
+
44
+ const existing = existsSync(transcriptPath) ? readFileSync(transcriptPath, 'utf8') : '';
45
+ const rows = parseLines(existing);
46
+ if (rows.some((row) => rowText(row).includes(GROK_HANDOFF_MARKER))) {
47
+ return { injected: false, reason: 'already_present' };
48
+ }
49
+
50
+ const reminder = {
51
+ type: 'user',
52
+ content: [{
53
+ type: 'text',
54
+ text: `<system-reminder ${GROK_HANDOFF_MARKER}>\n${injectionText}\n</system-reminder>`,
55
+ }],
56
+ synthetic_reason: GROK_HANDOFF_SYNTHETIC_REASON,
57
+ };
58
+
59
+ let insertAt = rows.length;
60
+ for (let i = rows.length - 1; i >= 0; i--) {
61
+ if (rows[i].type === 'user' && rowText(rows[i]).includes('<user_query>')) {
62
+ insertAt = i;
63
+ break;
64
+ }
65
+ }
66
+ rows.splice(insertAt, 0, reminder);
67
+
68
+ mkdirSync(dirname(transcriptPath), { recursive: true });
69
+ writeFileSync(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
70
+ return { injected: true, reason: null, insertAt };
71
+ }
@@ -0,0 +1,69 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ GROK_HANDOFF_SYNTHETIC_REASON,
9
+ injectGrokHandoffContext,
10
+ } from './grok-history-inject.mjs';
11
+
12
+ test('injectGrokHandoffContext inserts reminder before the latest user_query', () => {
13
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-inject-'));
14
+ const path = join(dir, 'chat_history.jsonl');
15
+ writeFileSync(
16
+ path,
17
+ [
18
+ JSON.stringify({ type: 'system', content: 'sys' }),
19
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: '<user_info>x</user_info>' }] }),
20
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: '<user_query>\nこれかな?\n</user_query>' }] }),
21
+ ].join('\n') + '\n',
22
+ );
23
+ try {
24
+ const result = injectGrokHandoffContext(path, 'old assistant body');
25
+ assert.equal(result.injected, true);
26
+ const rows = readFileSync(path, 'utf8')
27
+ .split('\n')
28
+ .filter(Boolean)
29
+ .map((line) => JSON.parse(line));
30
+ assert.equal(rows.length, 4);
31
+ assert.equal(rows[2].synthetic_reason, GROK_HANDOFF_SYNTHETIC_REASON);
32
+ assert.match(rows[2].content[0].text, /data-throughline-handoff="1"/);
33
+ assert.match(rows[2].content[0].text, /old assistant body/);
34
+ assert.match(rows[3].content[0].text, /これかな?/);
35
+ } finally {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ });
39
+
40
+ test('injectGrokHandoffContext appends when no user_query exists yet', () => {
41
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-inject-'));
42
+ const path = join(dir, 'chat_history.jsonl');
43
+ writeFileSync(path, `${JSON.stringify({ type: 'system', content: 'sys' })}\n`);
44
+ try {
45
+ const result = injectGrokHandoffContext(path, 'memory');
46
+ assert.equal(result.injected, true);
47
+ const rows = readFileSync(path, 'utf8')
48
+ .split('\n')
49
+ .filter(Boolean)
50
+ .map((line) => JSON.parse(line));
51
+ assert.equal(rows.at(-1).synthetic_reason, GROK_HANDOFF_SYNTHETIC_REASON);
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test('injectGrokHandoffContext is idempotent', () => {
58
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-inject-'));
59
+ const path = join(dir, 'chat_history.jsonl');
60
+ try {
61
+ assert.equal(injectGrokHandoffContext(path, 'memory').injected, true);
62
+ assert.deepEqual(injectGrokHandoffContext(path, 'memory'), {
63
+ injected: false,
64
+ reason: 'already_present',
65
+ });
66
+ } finally {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ });
@@ -34,10 +34,10 @@ function childEnv(home) {
34
34
  };
35
35
  }
36
36
 
37
- function runNode(args, { home, cwd = REPO_ROOT, input = '' }) {
37
+ function runNode(args, { home, cwd = REPO_ROOT, input = '', env = {} }) {
38
38
  return spawnSync(process.execPath, args, {
39
39
  cwd,
40
- env: childEnv(home),
40
+ env: { ...childEnv(home), ...env },
41
41
  input,
42
42
  encoding: 'utf8',
43
43
  });
@@ -74,6 +74,202 @@ test('hook modules can be imported without executing their hook body', () => {
74
74
  }
75
75
  });
76
76
 
77
+ test('Grok camelCase envelopes register a grok: session instead of no-op', () => {
78
+ const home = makeTempHome();
79
+ const project = makeTempProject();
80
+ const sessionId = '01a00aa2-50f8-7791-86fd-df2d27cf003e';
81
+ const common = {
82
+ sessionId,
83
+ cwd: project,
84
+ workspaceRoot: project,
85
+ timestamp: '2026-08-17T00:00:00Z',
86
+ permissionMode: 'default',
87
+ };
88
+
89
+ try {
90
+ const start = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
91
+ home,
92
+ cwd: project,
93
+ input: JSON.stringify({ ...common, hookEventName: 'session_start', source: 'startup' }),
94
+ });
95
+ assert.equal(start.status, 0, start.stderr);
96
+
97
+ const prompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
98
+ home,
99
+ cwd: project,
100
+ input: JSON.stringify({ ...common, hookEventName: 'user_prompt_submit', prompt: 'hello grok' }),
101
+ });
102
+ assert.equal(prompt.status, 0, prompt.stderr);
103
+
104
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
105
+ mkdirSync(historyDir, { recursive: true });
106
+ writeFileSync(
107
+ join(historyDir, 'chat_history.jsonl'),
108
+ [
109
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
110
+ JSON.stringify({ type: 'assistant', content: 'captured reply' }),
111
+ ].join('\n'),
112
+ );
113
+ const stop = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
114
+ home,
115
+ cwd: project,
116
+ input: JSON.stringify({
117
+ ...common,
118
+ hookEventName: 'stop',
119
+ lastAssistantMessage: 'captured reply',
120
+ }),
121
+ });
122
+ assert.equal(stop.status, 0, stop.stderr);
123
+
124
+ const db = openDb(home);
125
+ const row = db.prepare('SELECT session_id, project_path FROM sessions').get();
126
+ assert.equal(row.session_id, `grok:${sessionId}`);
127
+ assert.equal(row.project_path, project);
128
+ const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
129
+ assert.deepEqual(
130
+ bodies.map((b) => ({ role: b.role, text: b.text })),
131
+ [
132
+ { role: 'assistant', text: 'captured reply' },
133
+ { role: 'user', text: 'hello grok' },
134
+ ],
135
+ );
136
+ db.close();
137
+ } finally {
138
+ rmSync(project, { recursive: true, force: true });
139
+ rmSync(home, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ test('Grok Stop with updates.jsonl transcriptPath still captures L2 from chat_history', () => {
144
+ const home = makeTempHome();
145
+ const project = makeTempProject();
146
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
147
+ const common = {
148
+ sessionId,
149
+ cwd: project,
150
+ workspaceRoot: project,
151
+ timestamp: '2026-08-17T00:00:00Z',
152
+ permissionMode: 'default',
153
+ };
154
+
155
+ try {
156
+ const start = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
157
+ home,
158
+ cwd: project,
159
+ input: JSON.stringify({ ...common, hookEventName: 'session_start', source: 'startup' }),
160
+ });
161
+ assert.equal(start.status, 0, start.stderr);
162
+
163
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
164
+ mkdirSync(historyDir, { recursive: true });
165
+ writeFileSync(
166
+ join(historyDir, 'updates.jsonl'),
167
+ JSON.stringify({
168
+ method: '_x.ai/session/update',
169
+ params: { update: { sessionUpdate: 'hook_execution', event_name: 'stop' } },
170
+ }) + '\n',
171
+ );
172
+ writeFileSync(
173
+ join(historyDir, 'chat_history.jsonl'),
174
+ [
175
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
176
+ JSON.stringify({ type: 'assistant', content: 'captured reply' }),
177
+ ].join('\n'),
178
+ );
179
+ const stop = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
180
+ home,
181
+ cwd: project,
182
+ input: JSON.stringify({
183
+ ...common,
184
+ hookEventName: 'stop',
185
+ transcriptPath: join(historyDir, 'updates.jsonl'),
186
+ lastAssistantMessage: 'captured reply',
187
+ }),
188
+ });
189
+ assert.equal(stop.status, 0, stop.stderr);
190
+
191
+ const db = openDb(home);
192
+ const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
193
+ assert.deepEqual(
194
+ bodies.map((b) => ({ role: b.role, text: b.text })),
195
+ [
196
+ { role: 'assistant', text: 'captured reply' },
197
+ { role: 'user', text: 'hello grok' },
198
+ ],
199
+ );
200
+ db.close();
201
+ } finally {
202
+ rmSync(project, { recursive: true, force: true });
203
+ rmSync(home, { recursive: true, force: true });
204
+ }
205
+ });
206
+
207
+ test('Grok wrapped /tl prompt writes a grok: baton', () => {
208
+ const home = makeTempHome();
209
+ const project = makeTempProject();
210
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
211
+ try {
212
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
213
+ home,
214
+ cwd: project,
215
+ input: JSON.stringify({
216
+ sessionId,
217
+ cwd: project,
218
+ hookEventName: 'user_prompt_submit',
219
+ prompt:
220
+ '<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>',
221
+ }),
222
+ });
223
+ assert.equal(result.status, 0, result.stderr);
224
+ assert.match(result.stderr, /grok-continue exited/);
225
+
226
+ const db = openDb(home);
227
+ const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
228
+ assert.equal(row.project_path, project);
229
+ assert.equal(row.session_id, `grok:${sessionId}`);
230
+ db.close();
231
+ } finally {
232
+ rmSync(project, { recursive: true, force: true });
233
+ rmSync(home, { recursive: true, force: true });
234
+ }
235
+ });
236
+
237
+ test('Grok empty hook prompt still writes /tl baton from chat_history', () => {
238
+ const home = makeTempHome();
239
+ const project = makeTempProject();
240
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
241
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
242
+ mkdirSync(historyDir, { recursive: true });
243
+ writeFileSync(
244
+ join(historyDir, 'chat_history.jsonl'),
245
+ JSON.stringify({
246
+ type: 'user',
247
+ content: [{ type: 'text', text: '<user_query>\n/tl\n</user_query>\n<skill_information>x</skill_information>' }],
248
+ }) + '\n',
249
+ );
250
+ try {
251
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
252
+ home,
253
+ cwd: project,
254
+ input: JSON.stringify({
255
+ sessionId,
256
+ cwd: project,
257
+ hookEventName: 'user_prompt_submit',
258
+ }),
259
+ });
260
+ assert.equal(result.status, 0, result.stderr);
261
+ assert.match(result.stderr, /grok-continue exited/);
262
+
263
+ const db = openDb(home);
264
+ const row = db.prepare('SELECT session_id FROM handoff_batons').get();
265
+ assert.equal(row.session_id, `grok:${sessionId}`);
266
+ db.close();
267
+ } finally {
268
+ rmSync(project, { recursive: true, force: true });
269
+ rmSync(home, { recursive: true, force: true });
270
+ }
271
+ });
272
+
77
273
  test('prompt-submit subprocess writes a /tl baton into an isolated DB', () => {
78
274
  const home = makeTempHome();
79
275
  const project = makeTempProject();
@@ -89,6 +285,7 @@ test('prompt-submit subprocess writes a /tl baton into an isolated DB', () => {
89
285
  });
90
286
 
91
287
  assert.equal(result.status, 0, result.stderr);
288
+ assert.equal(result.stderr.includes('grok-continue'), false);
92
289
 
93
290
  const db = openDb(home);
94
291
  const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
@@ -184,6 +381,87 @@ test('prompt-submit: non-baton prompt does not write any baton', () => {
184
381
  }
185
382
  });
186
383
 
384
+ test('Grok first prompt injects handoff into chat_history instead of stdout', () => {
385
+ const home = makeTempHome();
386
+ const project = makeTempProject();
387
+ const oldId = '01a00aa2-50f8-7791-86fd-df2d27cf003e';
388
+ const newId = '01a00ce5-0169-7852-95c0-9e6b8cc50c06';
389
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), newId);
390
+ mkdirSync(historyDir, { recursive: true });
391
+ writeFileSync(
392
+ join(historyDir, 'chat_history.jsonl'),
393
+ [
394
+ JSON.stringify({ type: 'system', content: 'sys' }),
395
+ JSON.stringify({
396
+ type: 'user',
397
+ content: [{ type: 'text', text: '<user_query>\nこれかな?\n</user_query>' }],
398
+ }),
399
+ ].join('\n') + '\n',
400
+ );
401
+ try {
402
+ const baton = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
403
+ home,
404
+ cwd: project,
405
+ input: JSON.stringify({
406
+ sessionId: oldId,
407
+ cwd: project,
408
+ hookEventName: 'user_prompt_submit',
409
+ prompt: '/tl',
410
+ }),
411
+ });
412
+ assert.equal(baton.status, 0, baton.stderr);
413
+
414
+ const db = openDb(home);
415
+ db.prepare(
416
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
417
+ VALUES (?, ?, 'active', 1, 1)`,
418
+ ).run(`grok:${oldId}`, project);
419
+ db.prepare(
420
+ `INSERT INTO bodies
421
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
422
+ VALUES (?, ?, 1, 'assistant', 'old assistant body', 4, 2)`,
423
+ ).run(`grok:${oldId}`, `grok:${oldId}`);
424
+ db.close();
425
+
426
+ const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
427
+ home,
428
+ cwd: project,
429
+ input: JSON.stringify({
430
+ sessionId: newId,
431
+ cwd: project,
432
+ hookEventName: 'session_start',
433
+ source: 'new',
434
+ }),
435
+ });
436
+ assert.equal(started.status, 0, started.stderr);
437
+
438
+ const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
439
+ home,
440
+ cwd: project,
441
+ input: JSON.stringify({
442
+ sessionId: newId,
443
+ cwd: project,
444
+ hookEventName: 'user_prompt_submit',
445
+ prompt: 'これかな?',
446
+ }),
447
+ });
448
+ assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
449
+ assert.equal(firstPrompt.stdout, '', 'Grok must not rely on ignored hook stdout');
450
+
451
+ const rows = readFileSync(join(historyDir, 'chat_history.jsonl'), 'utf8')
452
+ .split('\n')
453
+ .filter(Boolean)
454
+ .map((line) => JSON.parse(line));
455
+ assert.equal(rows[1].synthetic_reason, 'system_reminder');
456
+ assert.match(rows[1].content[0].text, /data-throughline-handoff="1"/);
457
+ assert.match(rows[1].content[0].text, /old assistant body/);
458
+ assert.match(rows[2].content[0].text, /これかな?/);
459
+ } finally {
460
+ rmSync(project, { recursive: true, force: true });
461
+ rmSync(home, { recursive: true, force: true });
462
+ }
463
+ });
464
+
187
465
  test('two-phase: session-start registers intent only; first prompt consumes baton and injects', () => {
188
466
  const home = makeTempHome();
189
467
  const project = makeTempProject();
@@ -0,0 +1,51 @@
1
+ // Grok sends Claude-compatible hook commands with a camelCase wire
2
+ // (sessionId, hookEventName) and no session_id. Throughline treats that
3
+ // envelope as host=grok: normalize to the Claude snake_case contract and
4
+ // prefix session ids so they never mix with Claude predecessor search.
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ export const GROK_SESSION_PREFIX = 'grok:';
9
+
10
+ export function isGrokEnvelope(payload) {
11
+ return payload !== null
12
+ && typeof payload === 'object'
13
+ && typeof payload.sessionId === 'string'
14
+ && payload.sessionId.length > 0
15
+ && typeof payload.hookEventName === 'string'
16
+ && payload.hookEventName.length > 0
17
+ && !Object.hasOwn(payload, 'session_id');
18
+ }
19
+
20
+ export function grokBareSessionId(sessionId) {
21
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
22
+ return sessionId.startsWith(GROK_SESSION_PREFIX)
23
+ ? sessionId.slice(GROK_SESSION_PREFIX.length)
24
+ : sessionId;
25
+ }
26
+
27
+ export function deriveGrokChatHistoryPath(projectPath, sessionId, { home = homedir() } = {}) {
28
+ const bare = grokBareSessionId(sessionId);
29
+ if (!projectPath || !bare) return null;
30
+ return join(home, '.grok', 'sessions', encodeURIComponent(projectPath), bare, 'chat_history.jsonl');
31
+ }
32
+
33
+ export function normalizeHookPayload(payload, { home = homedir() } = {}) {
34
+ if (!isGrokEnvelope(payload)) return payload;
35
+ const cwd = typeof payload.cwd === 'string' && payload.cwd.length > 0
36
+ ? payload.cwd
37
+ : (typeof payload.workspaceRoot === 'string' ? payload.workspaceRoot : undefined);
38
+ // Live Grok Stop sets transcriptPath to updates.jsonl (sessionUpdate frames,
39
+ // no user/assistant rows). L2 lives in chat_history.jsonl only.
40
+ const transcriptPath = deriveGrokChatHistoryPath(cwd, payload.sessionId, { home });
41
+ return {
42
+ ...payload,
43
+ session_id: `${GROK_SESSION_PREFIX}${payload.sessionId}`,
44
+ cwd,
45
+ source: payload.source,
46
+ prompt: payload.prompt,
47
+ hook_event_name: payload.hookEventName,
48
+ transcript_path: transcriptPath,
49
+ last_assistant_message: payload.lastAssistantMessage,
50
+ };
51
+ }
@@ -0,0 +1,79 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join } from 'node:path';
4
+
5
+ import {
6
+ deriveGrokChatHistoryPath,
7
+ isGrokEnvelope,
8
+ normalizeHookPayload,
9
+ } from './hook-envelope.mjs';
10
+
11
+ test('isGrokEnvelope detects camelCase wire without session_id', () => {
12
+ assert.equal(
13
+ isGrokEnvelope({
14
+ sessionId: '01a00aa2-dead-beef',
15
+ hookEventName: 'session_start',
16
+ cwd: '/tmp/proj',
17
+ }),
18
+ true,
19
+ );
20
+ assert.equal(
21
+ isGrokEnvelope({
22
+ session_id: 'claude-session',
23
+ hook_event_name: 'SessionStart',
24
+ }),
25
+ false,
26
+ );
27
+ });
28
+
29
+ test('normalizeHookPayload prefixes grok: and derives chat_history path', () => {
30
+ const home = '/tmp/tl-home';
31
+ const cwd = '/Users/kite/Developer/dotagents';
32
+ const payload = normalizeHookPayload(
33
+ {
34
+ sessionId: '01a00aa2-dead-beef',
35
+ hookEventName: 'user_prompt_submit',
36
+ cwd,
37
+ prompt: 'hello',
38
+ lastAssistantMessage: 'hi',
39
+ },
40
+ { home },
41
+ );
42
+ assert.equal(payload.session_id, 'grok:01a00aa2-dead-beef');
43
+ assert.equal(payload.prompt, 'hello');
44
+ assert.equal(payload.last_assistant_message, 'hi');
45
+ assert.equal(
46
+ payload.transcript_path,
47
+ join(home, '.grok', 'sessions', encodeURIComponent(cwd), '01a00aa2-dead-beef', 'chat_history.jsonl'),
48
+ );
49
+ assert.equal(
50
+ deriveGrokChatHistoryPath(cwd, 'grok:01a00aa2-dead-beef', { home }),
51
+ payload.transcript_path,
52
+ );
53
+ });
54
+
55
+ test('normalizeHookPayload ignores Grok transcriptPath pointing at updates.jsonl', () => {
56
+ const home = '/tmp/tl-home';
57
+ const cwd = '/Users/kite/Developer/dotagents';
58
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
59
+ const payload = normalizeHookPayload(
60
+ {
61
+ sessionId,
62
+ hookEventName: 'stop',
63
+ cwd,
64
+ transcriptPath: join(
65
+ home,
66
+ '.grok',
67
+ 'sessions',
68
+ encodeURIComponent(cwd),
69
+ sessionId,
70
+ 'updates.jsonl',
71
+ ),
72
+ },
73
+ { home },
74
+ );
75
+ assert.equal(
76
+ payload.transcript_path,
77
+ join(home, '.grok', 'sessions', encodeURIComponent(cwd), sessionId, 'chat_history.jsonl'),
78
+ );
79
+ });