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.
- package/CHANGELOG.md +51 -0
- package/README.ja.md +53 -2
- package/README.md +65 -12
- package/bin/throughline.mjs +12 -1
- package/codex/skills/throughline/SKILL.md +17 -1
- package/docs/00_overview.md +9 -2
- package/docs/02_clear_auto_handoff_plan.md +6 -0
- package/docs/04_public_release_plan.md +2 -2
- package/docs/16_readonly_handoff_context_plan.md +9 -0
- package/docs/adr/0020-windows-ci-release-latency.md +23 -0
- 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 +280 -2
- package/src/hook-envelope.mjs +51 -0
- package/src/hook-envelope.test.mjs +79 -0
- package/src/prompt-submit.mjs +85 -16
- package/src/prompt-submit.test.mjs +58 -1
- package/src/session-start.mjs +3 -1
- package/src/transcript-reader-grok.test.mjs +37 -0
- package/src/transcript-reader.mjs +7 -3
- package/src/turn-processor.mjs +8 -5
package/src/prompt-submit.mjs
CHANGED
|
@@ -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 何か"
|
|
144
|
+
* 許容: "/tl", "/tl\n", "/tl 何か"。Grok の user_query 包装も見る。
|
|
114
145
|
*/
|
|
115
146
|
export function isBatonCommand(prompt) {
|
|
116
|
-
|
|
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",
|
|
152
|
+
* 許容: "/clear", Grok の alias "/new"。Grok の user_query 包装も見る。
|
|
126
153
|
*/
|
|
127
154
|
export function isClearCommand(prompt) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
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 });
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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 {
|
|
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
|
+
});
|
package/src/session-start.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
73
|
+
role,
|
|
70
74
|
content: text,
|
|
71
75
|
turn_number: turns.length,
|
|
72
76
|
timestamp: Number.isNaN(ts) ? null : ts,
|
package/src/turn-processor.mjs
CHANGED
|
@@ -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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
// アシスタント応答終了時刻まで追従する
|