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
|
@@ -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 {
|
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`,
|