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.
@@ -42,6 +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 { 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';
45
49
 
46
50
  // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
47
51
  const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
@@ -108,28 +112,61 @@ function markSpiked(sessionId) {
108
112
  writeFileSync(join(PROMPT_SPIKE_STATE_DIR, sessionId), '', 'utf8');
109
113
  }
110
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
+
111
142
  /**
112
143
  * プロンプトが /tl バトン発動コマンドか判定する。
113
- * 許容: "/tl", "/tl\n", "/tl 何か" (前後空白は trim 済み前提)
144
+ * 許容: "/tl", "/tl\n", "/tl 何か"。Grok user_query 包装も見る。
114
145
  */
115
146
  export function isBatonCommand(prompt) {
116
- if (typeof prompt !== 'string') return false;
117
- const trimmed = prompt.trim();
118
- if (trimmed === '/tl') return true;
119
- if (trimmed.startsWith('/tl ') || trimmed.startsWith('/tl\n')) return true;
120
- return false;
147
+ return isNamedSlashCommand(prompt, '/tl');
121
148
  }
122
149
 
123
150
  /**
124
151
  * プロンプトが /clear バトン発動コマンドか判定する。
125
- * 許容: "/clear", "/clear\n", "/clear 何か" (前後空白は trim 済み前提)
152
+ * 許容: "/clear", Grok の alias "/new"。Grok user_query 包装も見る。
126
153
  */
127
154
  export function isClearCommand(prompt) {
128
- if (typeof prompt !== 'string') return false;
129
- const trimmed = prompt.trim();
130
- if (trimmed === '/clear') return true;
131
- if (trimmed.startsWith('/clear ') || trimmed.startsWith('/clear\n')) return true;
132
- return false;
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 });
133
170
  }
134
171
 
135
172
  export async function run() {
@@ -142,7 +179,7 @@ export async function run() {
142
179
  process.stdin.on('end', resolve);
143
180
  });
144
181
 
145
- const payload = JSON.parse(raw);
182
+ const payload = normalizeHookPayload(JSON.parse(raw));
146
183
  const { session_id, cwd, prompt } = payload;
147
184
 
148
185
  // VSCode 新規プロジェクトへの tasks.json 自動プロビジョニング。
@@ -171,7 +208,17 @@ export async function run() {
171
208
  });
172
209
  if (handoff.attempted) {
173
210
  if (handoff.injectionText) {
174
- process.stdout.write(handoff.injectionText + '\n');
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
+ }
175
222
  }
176
223
  logDecision({
177
224
  ts: new Date(now).toISOString(),
@@ -191,8 +238,17 @@ export async function run() {
191
238
  }
192
239
  }
193
240
 
194
- const tlMatch = isBatonCommand(prompt);
195
- const clearMatch = !tlMatch && isClearCommand(prompt);
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);
196
252
 
197
253
  // Phase 0-5 spike: real user prompt (not /tl, not /clear) で、marker file あり、
198
254
  // session 未 spike なら chain (b) で JSONL に inject する。失敗しても prompt 自体は
@@ -225,6 +281,19 @@ export async function run() {
225
281
  trigger: tlMatch ? 'tl' : 'clear',
226
282
  });
227
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
+
228
297
  process.exit(0);
229
298
  }
230
299
 
@@ -1,6 +1,12 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { isBatonCommand, isClearCommand } from './prompt-submit.mjs';
3
+ import {
4
+ commandTextFromPrompt,
5
+ isBatonCommand,
6
+ isClearCommand,
7
+ launchGrokContinueAfterTl,
8
+ shouldLaunchGrokContinue,
9
+ } from './prompt-submit.mjs';
4
10
 
5
11
  test('isBatonCommand: bare /tl', () => {
6
12
  assert.equal(isBatonCommand('/tl'), true);
@@ -64,3 +70,54 @@ test('isClearCommand: rejects empty / non-string', () => {
64
70
  assert.equal(isClearCommand(undefined), false);
65
71
  assert.equal(isClearCommand(42), false);
66
72
  });
73
+
74
+ test('commandTextFromPrompt unwraps Grok user_query and leaves bare Claude text', () => {
75
+ assert.equal(commandTextFromPrompt('/tl'), '/tl');
76
+ assert.equal(
77
+ commandTextFromPrompt('<user_query>\n/tl\n</user_query>\n<skill_information>saved</skill_information>'),
78
+ '/tl',
79
+ );
80
+ });
81
+
82
+ test('shouldLaunchGrokContinue is only Grok /tl', () => {
83
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'tl' }), true);
84
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'clear' }), false);
85
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'old-session', trigger: 'tl' }), false);
86
+ assert.equal(shouldLaunchGrokContinue({ sessionId: 'codex:thread', trigger: 'tl' }), false);
87
+ });
88
+
89
+ test('launchGrokContinueAfterTl calls grok-continue with the source session', () => {
90
+ const calls = [];
91
+ const code = launchGrokContinueAfterTl({
92
+ sessionId: 'grok:abc',
93
+ cwd: '/work/Throughline',
94
+ continueRun: (argv, opts) => {
95
+ calls.push({ argv, opts });
96
+ return 0;
97
+ },
98
+ });
99
+ assert.equal(code, 0);
100
+ assert.deepEqual(calls, [{
101
+ argv: ['--session', 'grok:abc'],
102
+ opts: { cwd: '/work/Throughline' },
103
+ }]);
104
+ });
105
+
106
+ test('isBatonCommand: Grok user_query wrap around /tl', () => {
107
+ const wrapped =
108
+ '<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>';
109
+ assert.equal(isBatonCommand(wrapped), true);
110
+ });
111
+
112
+ test('isBatonCommand: Grok wrap does not treat skill body /tl as the command', () => {
113
+ const wrapped =
114
+ '<user_query>\n続けてくれ\n</user_query>\n<skill_information>\nType /tl to hand off.\n</skill_information>';
115
+ assert.equal(isBatonCommand(wrapped), false);
116
+ });
117
+
118
+ test('isClearCommand: Grok user_query wrap around /clear and /new', () => {
119
+ assert.equal(isClearCommand('<user_query>\n/clear\n</user_query>'), true);
120
+ assert.equal(isClearCommand('<user_query>\n/new\n</user_query>'), true);
121
+ assert.equal(isClearCommand('/new'), true);
122
+ assert.equal(isClearCommand('/newest'), false);
123
+ });
@@ -32,6 +32,7 @@ import { logDecision } from './decision-log.mjs';
32
32
  import { existsSync } from 'node:fs';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
+ import { normalizeHookPayload } from './hook-envelope.mjs';
35
36
 
36
37
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
37
38
 
@@ -61,6 +62,7 @@ function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
61
62
  AND merged_into IS NULL
62
63
  AND session_id != ?
63
64
  AND session_id NOT LIKE 'codex:%'
65
+ AND session_id NOT LIKE 'grok:%'
64
66
  ORDER BY updated_at DESC
65
67
  LIMIT 5`,
66
68
  )
@@ -90,7 +92,7 @@ export async function run() {
90
92
  process.stdin.on('end', resolve);
91
93
  });
92
94
 
93
- const payload = JSON.parse(raw);
95
+ const payload = normalizeHookPayload(JSON.parse(raw));
94
96
  const { session_id, cwd, source, transcript_path } = payload;
95
97
 
96
98
  if (!session_id) throw new Error('Missing session_id in SessionStart payload');
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { getLogicalTurnGroups, readTranscript } from './transcript-reader.mjs';
8
+
9
+ test('readTranscript accepts Grok chat_history.jsonl user/assistant rows', () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-transcript-'));
11
+ const path = join(dir, 'chat_history.jsonl');
12
+ writeFileSync(
13
+ path,
14
+ [
15
+ JSON.stringify({ type: 'system', content: 'ignore' }),
16
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
17
+ JSON.stringify({ type: 'assistant', content: 'captured reply' }),
18
+ JSON.stringify({ type: 'tool_result', content: 'not a turn' }),
19
+ ].join('\n'),
20
+ );
21
+ try {
22
+ const turns = readTranscript(path);
23
+ assert.deepEqual(
24
+ turns.map((t) => ({ role: t.role, content: t.content })),
25
+ [
26
+ { role: 'user', content: 'hello grok' },
27
+ { role: 'assistant', content: 'captured reply' },
28
+ ],
29
+ );
30
+ const groups = getLogicalTurnGroups(path);
31
+ assert.equal(groups.length, 1);
32
+ assert.equal(groups[0].user.content, 'hello grok');
33
+ assert.equal(groups[0].representative.content, 'captured reply');
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
@@ -59,14 +59,18 @@ export function readTranscript(transcriptPath) {
59
59
  if (entry.isSidechain === true) continue;
60
60
 
61
61
  const msg = entry.message;
62
- if (!msg || !msg.role || msg.content == null) continue;
62
+ const grokContent = entry.content;
63
+ const role = msg?.role ?? entry.type;
64
+ const rawContent = msg?.content ?? grokContent;
65
+ if (!role || rawContent == null) continue;
63
66
 
64
- const text = extractText(msg.content);
67
+ const text = extractText(rawContent);
65
68
  if (!text) continue;
66
69
 
67
70
  const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
71
+ // grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
68
72
  turns.push({
69
- role: msg.role,
73
+ role,
70
74
  content: text,
71
75
  turn_number: turns.length,
72
76
  timestamp: Number.isNaN(ts) ? null : ts,
@@ -46,6 +46,7 @@ import { readLatestUsage } from './transcript-usage.mjs';
46
46
  import { pathToFileURL } from 'node:url';
47
47
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
48
48
  import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
49
+ import { normalizeHookPayload } from './hook-envelope.mjs';
49
50
 
50
51
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
51
52
  export const L2_WINDOW = 20;
@@ -190,7 +191,7 @@ export async function run() {
190
191
  process.stdin.on('end', resolve);
191
192
  });
192
193
 
193
- const payload = JSON.parse(raw || '{}');
194
+ const payload = normalizeHookPayload(JSON.parse(raw || '{}'));
194
195
  const { session_id, transcript_path, cwd, last_assistant_message } = payload;
195
196
  if (!session_id) throw new Error('Missing session_id in Stop payload');
196
197
 
@@ -204,10 +205,12 @@ export async function run() {
204
205
  process.stderr.write(`[vscode-task] ${msg}\n`);
205
206
  }
206
207
 
207
- await waitForClaudeStopTranscriptFlush({
208
- transcriptPath: transcript_path,
209
- lastAssistantMessage: last_assistant_message,
210
- });
208
+ if (!session_id.startsWith('grok:')) {
209
+ await waitForClaudeStopTranscriptFlush({
210
+ transcriptPath: transcript_path,
211
+ lastAssistantMessage: last_assistant_message,
212
+ });
213
+ }
211
214
 
212
215
  // Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
213
216
  // アシスタント応答終了時刻まで追従する