throughline 0.9.1 → 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.
- package/CHANGELOG.md +28 -0
- package/README.ja.md +40 -2
- package/README.md +42 -3
- package/bin/throughline.mjs +12 -1
- package/docs/00_overview.md +2 -0
- package/docs/02_clear_auto_handoff_plan.md +6 -0
- package/docs/04_public_release_plan.md +2 -2
- package/docs/adr/0021-grok-host-capture.md +60 -0
- package/docs/plan_grok-successor-launch.md +99 -0
- package/package.json +4 -2
- package/src/cli/grok-continue.mjs +197 -0
- package/src/cli/grok-continue.test.mjs +239 -0
- package/src/cli/handoff-context.mjs +19 -0
- package/src/cli/handoff-context.test.mjs +13 -0
- package/src/cli/install.mjs +62 -0
- package/src/cli/install.test.mjs +46 -0
- package/src/grok-history-inject.mjs +71 -0
- package/src/grok-history-inject.test.mjs +69 -0
- package/src/hook-entrypoints.test.mjs +257 -44
- package/src/hook-envelope.mjs +43 -4
- package/src/hook-envelope.test.mjs +79 -0
- package/src/prompt-submit.mjs +85 -18
- package/src/prompt-submit.test.mjs +58 -1
- package/src/session-start.mjs +3 -3
- package/src/transcript-reader-grok.test.mjs +37 -0
- package/src/transcript-reader.mjs +7 -3
- package/src/turn-processor.mjs +8 -7
|
@@ -74,65 +74,196 @@ test('hook modules can be imported without executing their hook body', () => {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test('Grok camelCase envelopes
|
|
77
|
+
test('Grok camelCase envelopes register a grok: session instead of no-op', () => {
|
|
78
78
|
const home = makeTempHome();
|
|
79
79
|
const project = makeTempProject();
|
|
80
|
+
const sessionId = '01a00aa2-50f8-7791-86fd-df2d27cf003e';
|
|
80
81
|
const common = {
|
|
81
|
-
sessionId
|
|
82
|
+
sessionId,
|
|
82
83
|
cwd: project,
|
|
83
84
|
workspaceRoot: project,
|
|
84
|
-
timestamp: '2026-08-
|
|
85
|
+
timestamp: '2026-08-17T00:00:00Z',
|
|
85
86
|
permissionMode: 'default',
|
|
86
87
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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({
|
|
102
117
|
...common,
|
|
103
118
|
hookEventName: 'stop',
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
};
|
|
112
154
|
|
|
113
155
|
try {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}),
|
|
122
188
|
});
|
|
189
|
+
assert.equal(stop.status, 0, stop.stderr);
|
|
123
190
|
|
|
191
|
+
const db = openDb(home);
|
|
192
|
+
const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
|
|
124
193
|
assert.deepEqual(
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
{
|
|
131
|
-
results: cases.map(({ event }) => ({ event, status: 0, stdout: '', stderr: '' })),
|
|
132
|
-
throughlineStateExists: false,
|
|
133
|
-
vscodeTaskExists: false,
|
|
134
|
-
},
|
|
194
|
+
bodies.map((b) => ({ role: b.role, text: b.text })),
|
|
195
|
+
[
|
|
196
|
+
{ role: 'assistant', text: 'captured reply' },
|
|
197
|
+
{ role: 'user', text: 'hello grok' },
|
|
198
|
+
],
|
|
135
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();
|
|
136
267
|
} finally {
|
|
137
268
|
rmSync(project, { recursive: true, force: true });
|
|
138
269
|
rmSync(home, { recursive: true, force: true });
|
|
@@ -154,6 +285,7 @@ test('prompt-submit subprocess writes a /tl baton into an isolated DB', () => {
|
|
|
154
285
|
});
|
|
155
286
|
|
|
156
287
|
assert.equal(result.status, 0, result.stderr);
|
|
288
|
+
assert.equal(result.stderr.includes('grok-continue'), false);
|
|
157
289
|
|
|
158
290
|
const db = openDb(home);
|
|
159
291
|
const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
|
|
@@ -249,6 +381,87 @@ test('prompt-submit: non-baton prompt does not write any baton', () => {
|
|
|
249
381
|
}
|
|
250
382
|
});
|
|
251
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
|
+
|
|
252
465
|
test('two-phase: session-start registers intent only; first prompt consumes baton and injects', () => {
|
|
253
466
|
const home = makeTempHome();
|
|
254
467
|
const project = makeTempProject();
|
package/src/hook-envelope.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
// Grok
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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) {
|
|
5
11
|
return payload !== null
|
|
6
12
|
&& typeof payload === 'object'
|
|
7
13
|
&& typeof payload.sessionId === 'string'
|
|
@@ -10,3 +16,36 @@ export function isUnsupportedNonClaudeEnvelope(payload) {
|
|
|
10
16
|
&& payload.hookEventName.length > 0
|
|
11
17
|
&& !Object.hasOwn(payload, 'session_id');
|
|
12
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
|
+
});
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -42,7 +42,10 @@ import { join, dirname } from 'node:path';
|
|
|
42
42
|
import { homedir } from 'node:os';
|
|
43
43
|
import { pathToFileURL } from 'node:url';
|
|
44
44
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
45
|
-
import {
|
|
45
|
+
import { GROK_SESSION_PREFIX, normalizeHookPayload } from './hook-envelope.mjs';
|
|
46
|
+
import { injectGrokHandoffContext } from './grok-history-inject.mjs';
|
|
47
|
+
import { readTranscript } from './transcript-reader.mjs';
|
|
48
|
+
import { run as runGrokContinue } from './cli/grok-continue.mjs';
|
|
46
49
|
|
|
47
50
|
// Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
|
|
48
51
|
const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
|
|
@@ -109,28 +112,61 @@ function markSpiked(sessionId) {
|
|
|
109
112
|
writeFileSync(join(PROMPT_SPIKE_STATE_DIR, sessionId), '', 'utf8');
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
const USER_QUERY_RE = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/i;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Grok は hook prompt と chat_history を
|
|
119
|
+
* `<user_query>/tl</user_query>` + skill 本文で包む。
|
|
120
|
+
* Claude の裸 `/tl` はそのまま返す。
|
|
121
|
+
*/
|
|
122
|
+
export function commandTextFromPrompt(prompt) {
|
|
123
|
+
if (typeof prompt !== 'string') return '';
|
|
124
|
+
const match = prompt.match(USER_QUERY_RE);
|
|
125
|
+
return (match ? match[1] : prompt).trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isNamedSlashCommand(prompt, name) {
|
|
129
|
+
const text = commandTextFromPrompt(prompt);
|
|
130
|
+
if (!text) return false;
|
|
131
|
+
return text === name || text.startsWith(`${name} `) || text.startsWith(`${name}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function lastUserPromptText(transcriptPath) {
|
|
135
|
+
const turns = readTranscript(transcriptPath);
|
|
136
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
137
|
+
if (turns[i].role === 'user') return turns[i].content;
|
|
138
|
+
}
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
/**
|
|
113
143
|
* プロンプトが /tl バトン発動コマンドか判定する。
|
|
114
|
-
* 許容: "/tl", "/tl\n", "/tl 何か"
|
|
144
|
+
* 許容: "/tl", "/tl\n", "/tl 何か"。Grok の user_query 包装も見る。
|
|
115
145
|
*/
|
|
116
146
|
export function isBatonCommand(prompt) {
|
|
117
|
-
|
|
118
|
-
const trimmed = prompt.trim();
|
|
119
|
-
if (trimmed === '/tl') return true;
|
|
120
|
-
if (trimmed.startsWith('/tl ') || trimmed.startsWith('/tl\n')) return true;
|
|
121
|
-
return false;
|
|
147
|
+
return isNamedSlashCommand(prompt, '/tl');
|
|
122
148
|
}
|
|
123
149
|
|
|
124
150
|
/**
|
|
125
151
|
* プロンプトが /clear バトン発動コマンドか判定する。
|
|
126
|
-
* 許容: "/clear",
|
|
152
|
+
* 許容: "/clear", Grok の alias "/new"。Grok の user_query 包装も見る。
|
|
127
153
|
*/
|
|
128
154
|
export function isClearCommand(prompt) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return
|
|
155
|
+
return isNamedSlashCommand(prompt, '/clear') || isNamedSlashCommand(prompt, '/new');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function shouldLaunchGrokContinue({ sessionId, trigger }) {
|
|
159
|
+
return trigger === 'tl'
|
|
160
|
+
&& typeof sessionId === 'string'
|
|
161
|
+
&& sessionId.startsWith(GROK_SESSION_PREFIX);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function launchGrokContinueAfterTl({
|
|
165
|
+
sessionId,
|
|
166
|
+
cwd,
|
|
167
|
+
continueRun = runGrokContinue,
|
|
168
|
+
}) {
|
|
169
|
+
return continueRun(['--session', sessionId], { cwd });
|
|
134
170
|
}
|
|
135
171
|
|
|
136
172
|
export async function run() {
|
|
@@ -143,8 +179,7 @@ export async function run() {
|
|
|
143
179
|
process.stdin.on('end', resolve);
|
|
144
180
|
});
|
|
145
181
|
|
|
146
|
-
const payload = JSON.parse(raw);
|
|
147
|
-
if (isUnsupportedNonClaudeEnvelope(payload)) return;
|
|
182
|
+
const payload = normalizeHookPayload(JSON.parse(raw));
|
|
148
183
|
const { session_id, cwd, prompt } = payload;
|
|
149
184
|
|
|
150
185
|
// VSCode 新規プロジェクトへの tasks.json 自動プロビジョニング。
|
|
@@ -173,7 +208,17 @@ export async function run() {
|
|
|
173
208
|
});
|
|
174
209
|
if (handoff.attempted) {
|
|
175
210
|
if (handoff.injectionText) {
|
|
176
|
-
|
|
211
|
+
if (session_id.startsWith(GROK_SESSION_PREFIX)) {
|
|
212
|
+
const injected = injectGrokHandoffContext(
|
|
213
|
+
payload.transcript_path,
|
|
214
|
+
handoff.injectionText,
|
|
215
|
+
);
|
|
216
|
+
if (!injected.injected) {
|
|
217
|
+
process.stderr.write(`[prompt-submit] grok chat_history inject skipped: ${injected.reason}\n`);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
process.stdout.write(handoff.injectionText + '\n');
|
|
221
|
+
}
|
|
177
222
|
}
|
|
178
223
|
logDecision({
|
|
179
224
|
ts: new Date(now).toISOString(),
|
|
@@ -193,8 +238,17 @@ export async function run() {
|
|
|
193
238
|
}
|
|
194
239
|
}
|
|
195
240
|
|
|
196
|
-
|
|
197
|
-
|
|
241
|
+
let commandPrompt = prompt;
|
|
242
|
+
if (
|
|
243
|
+
typeof session_id === 'string'
|
|
244
|
+
&& session_id.startsWith(GROK_SESSION_PREFIX)
|
|
245
|
+
&& !isBatonCommand(commandPrompt)
|
|
246
|
+
&& !isClearCommand(commandPrompt)
|
|
247
|
+
) {
|
|
248
|
+
commandPrompt = lastUserPromptText(payload.transcript_path);
|
|
249
|
+
}
|
|
250
|
+
const tlMatch = isBatonCommand(commandPrompt);
|
|
251
|
+
const clearMatch = !tlMatch && isClearCommand(commandPrompt);
|
|
198
252
|
|
|
199
253
|
// Phase 0-5 spike: real user prompt (not /tl, not /clear) で、marker file あり、
|
|
200
254
|
// session 未 spike なら chain (b) で JSONL に inject する。失敗しても prompt 自体は
|
|
@@ -227,6 +281,19 @@ export async function run() {
|
|
|
227
281
|
trigger: tlMatch ? 'tl' : 'clear',
|
|
228
282
|
});
|
|
229
283
|
|
|
284
|
+
if (shouldLaunchGrokContinue({
|
|
285
|
+
sessionId: session_id,
|
|
286
|
+
trigger: tlMatch ? 'tl' : 'clear',
|
|
287
|
+
})) {
|
|
288
|
+
const code = launchGrokContinueAfterTl({
|
|
289
|
+
sessionId: session_id,
|
|
290
|
+
cwd: projectPath,
|
|
291
|
+
});
|
|
292
|
+
if (code !== 0) {
|
|
293
|
+
process.stderr.write(`[prompt-submit] grok-continue exited ${code}\n`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
230
297
|
process.exit(0);
|
|
231
298
|
}
|
|
232
299
|
|