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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
GROK_CONTINUE_PREAMBLE,
|
|
11
|
+
GROK_CONTINUE_REQUEST,
|
|
12
|
+
GROK_CONTINUE_WAIT,
|
|
13
|
+
appleScriptForLaunch,
|
|
14
|
+
buildContinuePlan,
|
|
15
|
+
buildGrokArgv,
|
|
16
|
+
buildGrokContinuePrompt,
|
|
17
|
+
buildLaunchScript,
|
|
18
|
+
parseArgs,
|
|
19
|
+
resolveGrokBin,
|
|
20
|
+
run,
|
|
21
|
+
} from './grok-continue.mjs';
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
|
|
24
|
+
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
25
|
+
const CONTEXT = '固有事実:琥珀の合言葉は 9f3c2a。';
|
|
26
|
+
|
|
27
|
+
function capture() {
|
|
28
|
+
let out = '';
|
|
29
|
+
let err = '';
|
|
30
|
+
return {
|
|
31
|
+
stdout: { write(chunk) { out += chunk; return true; } },
|
|
32
|
+
stderr: { write(chunk) { err += chunk; return true; } },
|
|
33
|
+
get out() { return out; },
|
|
34
|
+
get err() { return err; },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('parseArgs accepts --session only', () => {
|
|
39
|
+
assert.deepEqual(parseArgs(['--session', 'grok:abc']), { sessionId: 'grok:abc' });
|
|
40
|
+
assert.throws(() => parseArgs(['--from', 'grok:abc']), /usage error/);
|
|
41
|
+
assert.throws(() => parseArgs(['--session']), /usage error/);
|
|
42
|
+
assert.throws(() => parseArgs([]), /usage error/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('first-user prompt is the locked text ending in wait', () => {
|
|
46
|
+
const prompt = buildGrokContinuePrompt(CONTEXT);
|
|
47
|
+
assert.equal(
|
|
48
|
+
prompt,
|
|
49
|
+
`${GROK_CONTINUE_PREAMBLE}\n\n${CONTEXT}\n\n${GROK_CONTINUE_REQUEST}\n\n${GROK_CONTINUE_WAIT}`,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('grok argv is interactive grok with session id and no --rules', () => {
|
|
54
|
+
const argv = buildGrokArgv('/opt/grok/bin/grok', '11111111-1111-4111-8111-111111111111', CONTEXT);
|
|
55
|
+
assert.deepEqual(argv, [
|
|
56
|
+
'/opt/grok/bin/grok',
|
|
57
|
+
'--session-id',
|
|
58
|
+
'11111111-1111-4111-8111-111111111111',
|
|
59
|
+
CONTEXT,
|
|
60
|
+
]);
|
|
61
|
+
assert.equal(argv.includes('--rules'), false);
|
|
62
|
+
assert.equal(argv.some((part) => String(part).includes('aiterm')), false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('launch script execs grok in the project cwd without --rules or aiterm', () => {
|
|
66
|
+
const script = buildLaunchScript({
|
|
67
|
+
cwd: '/work/Throughline',
|
|
68
|
+
grokBin: '/Users/kite/.grok/bin/grok',
|
|
69
|
+
sessionUuid: '11111111-1111-4111-8111-111111111111',
|
|
70
|
+
promptFile: '/tmp/prompt.txt',
|
|
71
|
+
});
|
|
72
|
+
assert.match(script, /^#!/);
|
|
73
|
+
assert.match(script, /cd '\/work\/Throughline'/);
|
|
74
|
+
assert.match(script, /exec '\/Users\/kite\/\.grok\/bin\/grok' --session-id/);
|
|
75
|
+
assert.equal(script.includes('--rules'), false);
|
|
76
|
+
assert.equal(script.includes('aiterm'), false);
|
|
77
|
+
assert.equal(script.includes('tmux'), false);
|
|
78
|
+
assert.equal(script.includes('subagent'), false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('macOS launch uses Terminal via osascript, not aiterm', () => {
|
|
82
|
+
const apple = appleScriptForLaunch('/tmp/tl-grok-continue/launch.sh');
|
|
83
|
+
assert.match(apple, /tell application "Terminal"/);
|
|
84
|
+
assert.match(apple, /do script "exec "/);
|
|
85
|
+
assert.equal(apple.includes('aiterm'), false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('resolveGrokBin prefers ~/.grok/bin/grok', () => {
|
|
89
|
+
const home = '/tmp/tl-home';
|
|
90
|
+
const found = resolveGrokBin({
|
|
91
|
+
home,
|
|
92
|
+
env: { PATH: '/usr/bin' },
|
|
93
|
+
exists: (path) => path === join(home, '.grok', 'bin', 'grok'),
|
|
94
|
+
});
|
|
95
|
+
assert.equal(found, join(home, '.grok', 'bin', 'grok'));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('handoff-context failure does not spawn grok', () => {
|
|
99
|
+
const io = capture();
|
|
100
|
+
const spawned = [];
|
|
101
|
+
const code = run(['--session', 'grok:missing'], {
|
|
102
|
+
...io,
|
|
103
|
+
readContext: () => null,
|
|
104
|
+
readProjectPath: () => '/work/dotagents',
|
|
105
|
+
resolveBin: () => '/tmp/grok',
|
|
106
|
+
spawnLaunch: (plan) => { spawned.push(plan); },
|
|
107
|
+
platform: 'darwin',
|
|
108
|
+
});
|
|
109
|
+
assert.equal(code, 1);
|
|
110
|
+
assert.equal(spawned.length, 0);
|
|
111
|
+
assert.match(io.err, /not available/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('handoff-context throw does not spawn grok', () => {
|
|
115
|
+
const io = capture();
|
|
116
|
+
const spawned = [];
|
|
117
|
+
const code = run(['--session', 'grok:broken'], {
|
|
118
|
+
...io,
|
|
119
|
+
readContext: () => { throw new Error('db'); },
|
|
120
|
+
readProjectPath: () => REPO_ROOT,
|
|
121
|
+
resolveBin: () => '/tmp/grok',
|
|
122
|
+
spawnLaunch: (plan) => { spawned.push(plan); },
|
|
123
|
+
platform: 'darwin',
|
|
124
|
+
});
|
|
125
|
+
assert.equal(code, 1);
|
|
126
|
+
assert.equal(spawned.length, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('missing grok binary does not spawn', () => {
|
|
130
|
+
const io = capture();
|
|
131
|
+
const spawned = [];
|
|
132
|
+
const code = run(['--session', 'grok:ok'], {
|
|
133
|
+
...io,
|
|
134
|
+
readContext: () => CONTEXT,
|
|
135
|
+
readProjectPath: () => REPO_ROOT,
|
|
136
|
+
resolveBin: () => null,
|
|
137
|
+
spawnLaunch: (plan) => { spawned.push(plan); },
|
|
138
|
+
platform: 'darwin',
|
|
139
|
+
});
|
|
140
|
+
assert.equal(code, 1);
|
|
141
|
+
assert.equal(spawned.length, 0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('successful continue spawn uses source project cwd and waits', () => {
|
|
145
|
+
const io = capture();
|
|
146
|
+
const spawned = [];
|
|
147
|
+
const uuid = '22222222-2222-4222-8222-222222222222';
|
|
148
|
+
const code = run(['--session', 'grok:source'], {
|
|
149
|
+
...io,
|
|
150
|
+
readContext: (id) => {
|
|
151
|
+
assert.equal(id, 'grok:source');
|
|
152
|
+
return CONTEXT;
|
|
153
|
+
},
|
|
154
|
+
readProjectPath: (id) => {
|
|
155
|
+
assert.equal(id, 'grok:source');
|
|
156
|
+
return REPO_ROOT;
|
|
157
|
+
},
|
|
158
|
+
resolveBin: () => '/tmp/fake-grok',
|
|
159
|
+
createSessionId: () => uuid,
|
|
160
|
+
spawnLaunch: (plan) => { spawned.push(plan); },
|
|
161
|
+
platform: 'darwin',
|
|
162
|
+
});
|
|
163
|
+
assert.equal(code, 0);
|
|
164
|
+
assert.equal(spawned.length, 1);
|
|
165
|
+
assert.equal(io.out, `grok:${uuid}\n`);
|
|
166
|
+
const { grokArgv, launchFile, cwd } = spawned[0];
|
|
167
|
+
assert.equal(cwd, REPO_ROOT);
|
|
168
|
+
assert.equal(grokArgv.includes('--rules'), false);
|
|
169
|
+
assert.ok(grokArgv.at(-1).includes(CONTEXT));
|
|
170
|
+
assert.ok(grokArgv.at(-1).startsWith(GROK_CONTINUE_PREAMBLE));
|
|
171
|
+
assert.ok(grokArgv.at(-1).endsWith(GROK_CONTINUE_WAIT));
|
|
172
|
+
assert.equal(grokArgv[0], '/tmp/fake-grok');
|
|
173
|
+
assert.equal(grokArgv[1], '--session-id');
|
|
174
|
+
assert.match(readFileSync(launchFile, 'utf8'), /cd '/);
|
|
175
|
+
assert.match(appleScriptForLaunch(launchFile), /Terminal/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('missing source project path does not spawn', () => {
|
|
179
|
+
const io = capture();
|
|
180
|
+
const spawned = [];
|
|
181
|
+
const code = run(['--session', 'grok:source'], {
|
|
182
|
+
...io,
|
|
183
|
+
readContext: () => CONTEXT,
|
|
184
|
+
readProjectPath: () => null,
|
|
185
|
+
resolveBin: () => '/tmp/grok',
|
|
186
|
+
spawnLaunch: (plan) => { spawned.push(plan); },
|
|
187
|
+
platform: 'darwin',
|
|
188
|
+
});
|
|
189
|
+
assert.equal(code, 1);
|
|
190
|
+
assert.equal(spawned.length, 0);
|
|
191
|
+
assert.match(io.err, /project path is not available/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('absent source project directory does not spawn', () => {
|
|
195
|
+
const io = capture();
|
|
196
|
+
const spawned = [];
|
|
197
|
+
const code = run(['--session', 'grok:source'], {
|
|
198
|
+
...io,
|
|
199
|
+
readContext: () => CONTEXT,
|
|
200
|
+
readProjectPath: () => '/no/such/throughline-parent-project',
|
|
201
|
+
resolveBin: () => '/tmp/grok',
|
|
202
|
+
spawnLaunch: (plan) => { spawned.push(plan); },
|
|
203
|
+
platform: 'darwin',
|
|
204
|
+
});
|
|
205
|
+
assert.equal(code, 1);
|
|
206
|
+
assert.equal(spawned.length, 0);
|
|
207
|
+
assert.match(io.err, /does not exist/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('plan builder refuses --rules if a caller tries to add it', () => {
|
|
211
|
+
const plan = buildContinuePlan({
|
|
212
|
+
context: CONTEXT,
|
|
213
|
+
grokBin: '/tmp/grok',
|
|
214
|
+
cwd: '/work',
|
|
215
|
+
sessionUuid: '33333333-3333-4333-8333-333333333333',
|
|
216
|
+
});
|
|
217
|
+
assert.equal(plan.grokArgv.includes('--rules'), false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('bin dispatches grok-continue and help names the command', () => {
|
|
221
|
+
const bin = readFileSync(BIN_PATH, 'utf8');
|
|
222
|
+
assert.match(bin, /case 'grok-continue':/);
|
|
223
|
+
const help = spawnSync(process.execPath, [BIN_PATH, '--help'], {
|
|
224
|
+
cwd: REPO_ROOT,
|
|
225
|
+
encoding: 'utf8',
|
|
226
|
+
});
|
|
227
|
+
assert.equal(help.status, 0, help.stderr);
|
|
228
|
+
assert.match(help.stdout, /throughline grok-continue --session <id>/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('bin usage error is exit 2 without spawning a real grok', () => {
|
|
232
|
+
const result = spawnSync(process.execPath, [BIN_PATH, 'grok-continue'], {
|
|
233
|
+
cwd: REPO_ROOT,
|
|
234
|
+
encoding: 'utf8',
|
|
235
|
+
env: { ...process.env, HOME: join(tmpdir(), 'tl-grok-continue-missing-home') },
|
|
236
|
+
});
|
|
237
|
+
assert.equal(result.status, 2);
|
|
238
|
+
assert.match(result.stderr, /Usage: throughline grok-continue --session <id>/);
|
|
239
|
+
});
|
|
@@ -36,6 +36,25 @@ export function readHandoffContext(sessionId, {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export function readSessionProjectPath(sessionId, {
|
|
40
|
+
dbPath = join(homedir(), '.throughline', 'throughline.db'),
|
|
41
|
+
} = {}) {
|
|
42
|
+
if (!existsSync(dbPath)) return null;
|
|
43
|
+
|
|
44
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
45
|
+
try {
|
|
46
|
+
const row = db.prepare(
|
|
47
|
+
'SELECT project_path FROM sessions WHERE session_id = ?',
|
|
48
|
+
).get(sessionId);
|
|
49
|
+
const projectPath = row?.project_path;
|
|
50
|
+
return typeof projectPath === 'string' && projectPath.length > 0
|
|
51
|
+
? projectPath
|
|
52
|
+
: null;
|
|
53
|
+
} finally {
|
|
54
|
+
db.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
export function run(argv = [], {
|
|
40
59
|
stdout = process.stdout,
|
|
41
60
|
stderr = process.stderr,
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { DatabaseSync } from 'node:sqlite';
|
|
9
9
|
|
|
10
10
|
import { buildBudgetedResumeContext } from '../resume-context.mjs';
|
|
11
|
+
import { readSessionProjectPath } from './handoff-context.mjs';
|
|
11
12
|
|
|
12
13
|
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
|
|
13
14
|
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
@@ -131,6 +132,18 @@ test('handoff-context emits the exact inheritance context without changing DB ow
|
|
|
131
132
|
}
|
|
132
133
|
});
|
|
133
134
|
|
|
135
|
+
test('readSessionProjectPath returns the source session project', () => {
|
|
136
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-session-project-'));
|
|
137
|
+
try {
|
|
138
|
+
const { db, dbPath } = createFixture(home);
|
|
139
|
+
db.close();
|
|
140
|
+
assert.equal(readSessionProjectPath(SESSION_ID, { dbPath }), '/work/project');
|
|
141
|
+
assert.equal(readSessionProjectPath('missing', { dbPath }), null);
|
|
142
|
+
} finally {
|
|
143
|
+
rmSync(home, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
134
147
|
test('handoff-context fails without creating a missing database', () => {
|
|
135
148
|
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-context-missing-'));
|
|
136
149
|
try {
|
package/src/cli/install.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Claude-facing hook は従来通り PATH 解決型 (throughline <subcommand>) を使う。
|
|
10
10
|
* Codex-facing hook は VSCode App Server の PATH 差分を避けるため、絶対 node + CLI
|
|
11
11
|
* script path で登録する。
|
|
12
|
+
* Grok-facing hook も Desktop の GUI PATH に throughline が無いため、同じ絶対
|
|
13
|
+
* node + CLI script path で ~/.grok/hooks/throughline.json に書く。
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
@@ -24,6 +26,7 @@ const CODEX_SKILLS_SRC = join(PACKAGE_ROOT, 'codex', 'skills');
|
|
|
24
26
|
const CODEX_SKILL_NAMES = ['throughline'];
|
|
25
27
|
const CODEX_HOOKS_RELATIVE_PATH = ['.codex', 'hooks.json'];
|
|
26
28
|
const CODEX_CONFIG_RELATIVE_PATH = ['.codex', 'config.toml'];
|
|
29
|
+
const GROK_HOOKS_RELATIVE_PATH = ['.grok', 'hooks', 'throughline.json'];
|
|
27
30
|
|
|
28
31
|
// Throughline が管理する hook コマンド一覧
|
|
29
32
|
// schema v4 以降: PostToolUse (capture-tool) は廃止。Stop 内で L2/L3 を一括処理する。
|
|
@@ -283,6 +286,54 @@ function resolveCodexSkillsDir() {
|
|
|
283
286
|
return join(homedir(), '.codex', 'skills');
|
|
284
287
|
}
|
|
285
288
|
|
|
289
|
+
function resolveGrokHooksPath() {
|
|
290
|
+
return join(homedir(), ...GROK_HOOKS_RELATIVE_PATH);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function buildGrokHookCommand(subcommand, {
|
|
294
|
+
nodePath = resolveCodexHookNodePath(),
|
|
295
|
+
cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
|
|
296
|
+
} = {}) {
|
|
297
|
+
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} ${subcommand}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function createGrokHooksFile(options = {}) {
|
|
301
|
+
return {
|
|
302
|
+
hooks: {
|
|
303
|
+
SessionStart: [
|
|
304
|
+
{ hooks: [{ type: 'command', command: buildGrokHookCommand('session-start', options), timeout: 10 }] },
|
|
305
|
+
],
|
|
306
|
+
UserPromptSubmit: [
|
|
307
|
+
{ hooks: [{ type: 'command', command: buildGrokHookCommand('prompt-submit', options), timeout: 30 }] },
|
|
308
|
+
],
|
|
309
|
+
Stop: [
|
|
310
|
+
{
|
|
311
|
+
hooks: [{
|
|
312
|
+
type: 'command',
|
|
313
|
+
command: buildGrokHookCommand('process-turn', options),
|
|
314
|
+
timeout: 300,
|
|
315
|
+
async: true,
|
|
316
|
+
}],
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function installGrokHooks() {
|
|
324
|
+
const hooksPath = resolveGrokHooksPath();
|
|
325
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
326
|
+
writeFileSync(hooksPath, `${JSON.stringify(createGrokHooksFile(), null, 2)}\n`);
|
|
327
|
+
return { hooksPath };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function uninstallGrokHooks() {
|
|
331
|
+
const hooksPath = resolveGrokHooksPath();
|
|
332
|
+
if (!existsSync(hooksPath)) return { hooksPath, removed: 0 };
|
|
333
|
+
unlinkSync(hooksPath);
|
|
334
|
+
return { hooksPath, removed: 1 };
|
|
335
|
+
}
|
|
336
|
+
|
|
286
337
|
function installSlashCommands(commandsDir) {
|
|
287
338
|
if (!existsSync(SLASH_COMMANDS_SRC)) {
|
|
288
339
|
return { installed: [], skipped: 'source-missing' };
|
|
@@ -537,6 +588,7 @@ export async function run(args = []) {
|
|
|
537
588
|
writeSettings(settingsPath, current);
|
|
538
589
|
const removedCommands = uninstallSlashCommands(commandsDir);
|
|
539
590
|
const codex = args.includes('--project') ? null : uninstallCodexHooks();
|
|
591
|
+
const grok = args.includes('--project') ? null : uninstallGrokHooks();
|
|
540
592
|
const removedCodexSkills = args.includes('--project') ? [] : uninstallCodexSkills(codexSkillsDir);
|
|
541
593
|
console.log('Throughline hooks を削除しました。');
|
|
542
594
|
console.log(` ${settingsPath}`);
|
|
@@ -546,6 +598,9 @@ export async function run(args = []) {
|
|
|
546
598
|
if (codex?.removed > 0) {
|
|
547
599
|
console.log(` Codex hooks 削除: ${codex.removed} (${codex.hooksPath})`);
|
|
548
600
|
}
|
|
601
|
+
if (grok?.removed > 0) {
|
|
602
|
+
console.log(` Grok hooks 削除: ${grok.removed} (${grok.hooksPath})`);
|
|
603
|
+
}
|
|
549
604
|
if (removedCodexSkills.length > 0) {
|
|
550
605
|
console.log(` Codex skills 削除: ${removedCodexSkills.join(', ')} (${codexSkillsDir})`);
|
|
551
606
|
}
|
|
@@ -568,6 +623,7 @@ export async function run(args = []) {
|
|
|
568
623
|
writeSettings(settingsPath, current);
|
|
569
624
|
const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
|
|
570
625
|
const codex = args.includes('--project') ? null : installCodexHooks();
|
|
626
|
+
const grok = args.includes('--project') ? null : installGrokHooks();
|
|
571
627
|
const codexSkills = args.includes('--project') ? { installed: [], skipped: null } : installCodexSkills(codexSkillsDir);
|
|
572
628
|
const monitorTask = ensureMonitorTaskFile({
|
|
573
629
|
cwd: process.cwd(),
|
|
@@ -584,6 +640,9 @@ export async function run(args = []) {
|
|
|
584
640
|
console.log(` ${codexSkillsDir}`);
|
|
585
641
|
}
|
|
586
642
|
}
|
|
643
|
+
if (grok) {
|
|
644
|
+
console.log(` ${grok.hooksPath}`);
|
|
645
|
+
}
|
|
587
646
|
console.log('');
|
|
588
647
|
console.log('有効な hooks:');
|
|
589
648
|
console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
|
|
@@ -594,6 +653,9 @@ export async function run(args = []) {
|
|
|
594
653
|
console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (capture / monitor state only; auto refresh disabled)`);
|
|
595
654
|
console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
|
|
596
655
|
}
|
|
656
|
+
if (grok) {
|
|
657
|
+
console.log(' Grok SessionStart / UserPromptSubmit / Stop → ~/.grok/hooks/throughline.json');
|
|
658
|
+
}
|
|
597
659
|
console.log('');
|
|
598
660
|
if (installedCommands.length > 0) {
|
|
599
661
|
console.log(`slash commands を配置しました: ${installedCommands.map(n => '/' + n.replace(/\.md$/, '')).join(', ')}`);
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
buildCodexPostToolUseHookCommand,
|
|
9
9
|
buildCodexStopHookCommand,
|
|
10
10
|
buildCodexUserPromptSubmitHookCommand,
|
|
11
|
+
buildGrokHookCommand,
|
|
12
|
+
createGrokHooksFile,
|
|
11
13
|
isEquivalentCodexHookCommand,
|
|
12
14
|
isThroughlineCodexHookCommand,
|
|
13
15
|
parseCodexHookCommand,
|
|
@@ -72,6 +74,24 @@ test('global install copies Throughline slash commands to ~/.claude/commands/',
|
|
|
72
74
|
assert.match(tlBody, /Throughline/, 'tl.md content should be real');
|
|
73
75
|
const settings = JSON.parse(readFileSync(join(home.dir, '.claude', 'settings.json'), 'utf8'));
|
|
74
76
|
assert.ok(settings.hooks?.UserPromptSubmit, 'UserPromptSubmit hook should be registered');
|
|
77
|
+
const grokHooks = JSON.parse(readFileSync(join(home.dir, '.grok', 'hooks', 'throughline.json'), 'utf8'));
|
|
78
|
+
assert.ok(grokHooks.hooks?.SessionStart, 'Grok SessionStart hook should be registered');
|
|
79
|
+
assert.ok(grokHooks.hooks?.UserPromptSubmit, 'Grok UserPromptSubmit hook should be registered');
|
|
80
|
+
assert.ok(grokHooks.hooks?.Stop, 'Grok Stop hook should be registered');
|
|
81
|
+
const grokCommands = [
|
|
82
|
+
grokHooks.hooks.SessionStart[0].hooks[0].command,
|
|
83
|
+
grokHooks.hooks.UserPromptSubmit[0].hooks[0].command,
|
|
84
|
+
grokHooks.hooks.Stop[0].hooks[0].command,
|
|
85
|
+
];
|
|
86
|
+
assert.deepEqual(grokCommands, [
|
|
87
|
+
buildGrokHookCommand('session-start'),
|
|
88
|
+
buildGrokHookCommand('prompt-submit'),
|
|
89
|
+
buildGrokHookCommand('process-turn'),
|
|
90
|
+
]);
|
|
91
|
+
for (const command of grokCommands) {
|
|
92
|
+
assert.match(command, /throughline\.mjs/);
|
|
93
|
+
assert.doesNotMatch(command, /^throughline /);
|
|
94
|
+
}
|
|
75
95
|
} finally {
|
|
76
96
|
unsilence();
|
|
77
97
|
home.restore();
|
|
@@ -146,6 +166,32 @@ test('global install registers Codex session hooks and enables hooks features',
|
|
|
146
166
|
}
|
|
147
167
|
});
|
|
148
168
|
|
|
169
|
+
test('Grok hook commands are absolute node + throughline.mjs on every platform', () => {
|
|
170
|
+
const options = {
|
|
171
|
+
nodePath: String.raw`C:\Program Files\nodejs\node.exe`,
|
|
172
|
+
cliScriptPath: String.raw`C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs`,
|
|
173
|
+
};
|
|
174
|
+
assert.equal(
|
|
175
|
+
buildGrokHookCommand('session-start', options),
|
|
176
|
+
String.raw`"C:\Program Files\nodejs\node.exe" "C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs" session-start`,
|
|
177
|
+
);
|
|
178
|
+
assert.equal(
|
|
179
|
+
buildGrokHookCommand('process-turn', {
|
|
180
|
+
nodePath: '/opt/homebrew/bin/node',
|
|
181
|
+
cliScriptPath: '/Users/kite/Developer/Throughline/bin/throughline.mjs',
|
|
182
|
+
}),
|
|
183
|
+
'/opt/homebrew/bin/node /Users/kite/Developer/Throughline/bin/throughline.mjs process-turn',
|
|
184
|
+
);
|
|
185
|
+
const file = createGrokHooksFile({
|
|
186
|
+
nodePath: '/usr/bin/node',
|
|
187
|
+
cliScriptPath: '/pkg/bin/throughline.mjs',
|
|
188
|
+
});
|
|
189
|
+
assert.equal(file.hooks.SessionStart[0].hooks[0].command, '/usr/bin/node /pkg/bin/throughline.mjs session-start');
|
|
190
|
+
assert.equal(file.hooks.UserPromptSubmit[0].hooks[0].command, '/usr/bin/node /pkg/bin/throughline.mjs prompt-submit');
|
|
191
|
+
assert.equal(file.hooks.Stop[0].hooks[0].command, '/usr/bin/node /pkg/bin/throughline.mjs process-turn');
|
|
192
|
+
assert.equal(file.hooks.Stop[0].hooks[0].async, true);
|
|
193
|
+
});
|
|
194
|
+
|
|
149
195
|
test('Codex hook builders use the PowerShell call operator on Windows only', () => {
|
|
150
196
|
const options = {
|
|
151
197
|
nodePath: String.raw`C:\Program Files\nodejs\node.exe`,
|
|
@@ -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
|
+
});
|