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,197 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { delimiter, join } from 'node:path';
6
+
7
+ import { readHandoffContext, readSessionProjectPath } from './handoff-context.mjs';
8
+
9
+ export const GROK_CONTINUE_PREAMBLE = 'この発言は直前 Throughline 席の履歴を前提とする。';
10
+ export const GROK_CONTINUE_REQUEST = '直前の作業の自然な続きとして応答すること。';
11
+ export const GROK_CONTINUE_WAIT = 'この後ユーザーが指示を出す。何もせず待機すること。';
12
+
13
+ export function parseArgs(argv = []) {
14
+ if (
15
+ argv.length !== 2
16
+ || argv[0] !== '--session'
17
+ || typeof argv[1] !== 'string'
18
+ || argv[1].length === 0
19
+ ) {
20
+ throw new TypeError('usage error');
21
+ }
22
+ return { sessionId: argv[1] };
23
+ }
24
+
25
+ export function buildGrokContinuePrompt(context) {
26
+ if (typeof context !== 'string' || context.length === 0) {
27
+ throw new TypeError('empty context');
28
+ }
29
+ return `${GROK_CONTINUE_PREAMBLE}\n\n${context}\n\n${GROK_CONTINUE_REQUEST}\n\n${GROK_CONTINUE_WAIT}`;
30
+ }
31
+
32
+ export function shQuote(value) {
33
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
34
+ }
35
+
36
+ export function resolveGrokBin({
37
+ home = homedir(),
38
+ env = process.env,
39
+ exists = existsSync,
40
+ } = {}) {
41
+ const homeBin = join(home, '.grok', 'bin', 'grok');
42
+ if (exists(homeBin)) return homeBin;
43
+ for (const dir of String(env.PATH ?? '').split(delimiter)) {
44
+ if (!dir) continue;
45
+ const candidate = join(dir, 'grok');
46
+ if (exists(candidate)) return candidate;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ export function buildGrokArgv(grokBin, sessionUuid, prompt) {
52
+ return [grokBin, '--session-id', sessionUuid, prompt];
53
+ }
54
+
55
+ export function buildLaunchScript({ cwd, grokBin, sessionUuid, promptFile }) {
56
+ return [
57
+ '#!/bin/sh',
58
+ 'set -e',
59
+ `cd ${shQuote(cwd)}`,
60
+ `exec ${shQuote(grokBin)} --session-id ${shQuote(sessionUuid)} "$(cat ${shQuote(promptFile)})"`,
61
+ '',
62
+ ].join('\n');
63
+ }
64
+
65
+ export function appleScriptForLaunch(launchScriptPath) {
66
+ return [
67
+ 'tell application "Terminal"',
68
+ ' activate',
69
+ ` do script "exec " & quoted form of ${JSON.stringify(launchScriptPath)}`,
70
+ 'end tell',
71
+ '',
72
+ ].join('\n');
73
+ }
74
+
75
+ export function buildContinuePlan({
76
+ context,
77
+ grokBin,
78
+ cwd,
79
+ sessionUuid,
80
+ }) {
81
+ const prompt = buildGrokContinuePrompt(context);
82
+ const grokArgv = buildGrokArgv(grokBin, sessionUuid, prompt);
83
+ if (grokArgv.includes('--rules')) {
84
+ throw new Error('grok-continue must not pass --rules');
85
+ }
86
+ return {
87
+ prompt,
88
+ grokArgv,
89
+ cwd,
90
+ sessionUuid,
91
+ throughlineSessionId: `grok:${sessionUuid}`,
92
+ };
93
+ }
94
+
95
+ export function writeLaunchArtifacts({
96
+ cwd,
97
+ grokBin,
98
+ sessionUuid,
99
+ prompt,
100
+ tmp = tmpdir(),
101
+ }) {
102
+ const dir = join(tmp, `tl-grok-continue-${sessionUuid}`);
103
+ mkdirSync(dir, { recursive: true });
104
+ const promptFile = join(dir, 'prompt.txt');
105
+ const launchFile = join(dir, 'launch.sh');
106
+ writeFileSync(promptFile, prompt);
107
+ writeFileSync(launchFile, buildLaunchScript({
108
+ cwd,
109
+ grokBin,
110
+ sessionUuid,
111
+ promptFile,
112
+ }), { mode: 0o755 });
113
+ return { promptFile, launchFile };
114
+ }
115
+
116
+ export function defaultSpawnLaunch({ launchFile, spawnImpl = spawn }) {
117
+ const child = spawnImpl('osascript', ['-e', appleScriptForLaunch(launchFile)], {
118
+ detached: true,
119
+ stdio: 'ignore',
120
+ });
121
+ child.unref?.();
122
+ return child;
123
+ }
124
+
125
+ export function run(argv = [], {
126
+ stdout = process.stdout,
127
+ stderr = process.stderr,
128
+ readContext = readHandoffContext,
129
+ readProjectPath = readSessionProjectPath,
130
+ resolveBin = resolveGrokBin,
131
+ createSessionId = randomUUID,
132
+ platform = process.platform,
133
+ spawnLaunch = defaultSpawnLaunch,
134
+ } = {}) {
135
+ let sessionId;
136
+ try {
137
+ ({ sessionId } = parseArgs(argv));
138
+ } catch {
139
+ stderr.write('Usage: throughline grok-continue --session <id>\n');
140
+ return 2;
141
+ }
142
+
143
+ let context;
144
+ try {
145
+ context = readContext(sessionId);
146
+ } catch {
147
+ stderr.write('Throughline handoff context could not be read.\n');
148
+ return 1;
149
+ }
150
+ if (!context) {
151
+ stderr.write('Throughline handoff context is not available for that session.\n');
152
+ return 1;
153
+ }
154
+
155
+ let cwd;
156
+ try {
157
+ cwd = readProjectPath(sessionId);
158
+ } catch {
159
+ stderr.write('Throughline session project path could not be read.\n');
160
+ return 1;
161
+ }
162
+ if (!cwd) {
163
+ stderr.write('Throughline session project path is not available for that session.\n');
164
+ return 1;
165
+ }
166
+ if (!existsSync(cwd)) {
167
+ stderr.write('Throughline session project path does not exist.\n');
168
+ return 1;
169
+ }
170
+
171
+ const grokBin = resolveBin();
172
+ if (!grokBin) {
173
+ stderr.write('grok binary was not found.\n');
174
+ return 1;
175
+ }
176
+ if (platform !== 'darwin') {
177
+ stderr.write('throughline grok-continue requires macOS Terminal.\n');
178
+ return 1;
179
+ }
180
+
181
+ const sessionUuid = createSessionId();
182
+ const plan = buildContinuePlan({
183
+ context,
184
+ grokBin,
185
+ cwd,
186
+ sessionUuid,
187
+ });
188
+ const { launchFile } = writeLaunchArtifacts({
189
+ cwd: plan.cwd,
190
+ grokBin,
191
+ sessionUuid: plan.sessionUuid,
192
+ prompt: plan.prompt,
193
+ });
194
+ spawnLaunch({ launchFile, grokArgv: plan.grokArgv, cwd: plan.cwd });
195
+ stdout.write(`${plan.throughlineSessionId}\n`);
196
+ return 0;
197
+ }
@@ -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 {
@@ -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(', ')}`);
@@ -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`,