throughline 0.9.1 → 0.10.1
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 +52 -1
- 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/auditor-context.mjs +2 -1
- package/src/cli/codex-handoff-start.mjs +5 -23
- package/src/cli/codex-summarize.mjs +2 -1
- package/src/cli/codex-visibility-smoke.mjs +5 -4
- package/src/cli/grok-continue.mjs +184 -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/cli/runtime-errors.test.mjs +1 -1
- package/src/codex-capture.mjs +14 -18
- package/src/codex-handoff-model-smoke.mjs +1 -1
- package/src/codex-sidecar.mjs +1 -1
- package/src/completed-turn-receipts.mjs +2 -29
- package/src/completed-turn-receipts.test.mjs +1 -1
- package/src/grok-history-inject.mjs +71 -0
- package/src/grok-history-inject.test.mjs +69 -0
- package/src/haiku-summarizer.mjs +1 -1
- package/src/handoff-record.mjs +3 -1
- package/src/hook-entrypoints.test.mjs +257 -44
- package/src/hosts/claude.mjs +27 -0
- package/src/hosts/codex.mjs +28 -0
- package/src/hosts/grok.mjs +96 -0
- package/src/hosts/grok.test.mjs +79 -0
- package/src/hosts/identity.mjs +71 -0
- package/src/hosts/identity.test.mjs +65 -0
- package/src/hosts/index.mjs +37 -0
- package/src/os/macos-terminal.mjs +44 -0
- package/src/os/open-url.mjs +16 -0
- package/src/os/paths.mjs +12 -0
- package/src/os/shell.mjs +16 -0
- package/src/os/windows-acl.mjs +57 -0
- package/src/project-path.mjs +4 -4
- package/src/prompt-submit.mjs +53 -18
- package/src/prompt-submit.test.mjs +75 -1
- package/src/runtime-error-hook.test.mjs +1 -1
- package/src/runtime-error-store.mjs +3 -40
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +13 -5
- package/src/state-file.mjs +6 -5
- package/src/token-monitor.mjs +7 -6
- package/src/transcript-reader-grok.test.mjs +37 -0
- package/src/transcript-reader.mjs +7 -3
- package/src/turn-processor.mjs +8 -7
- package/src/hook-envelope.mjs +0 -12
- /package/src/{portable-spawn-sync.mjs → os/portable-spawn-sync.mjs} +0 -0
- /package/src/{portable-spawn-sync.test.mjs → os/portable-spawn-sync.test.mjs} +0 -0
- /package/src/{windows-acl-test-helper.mjs → os/windows-acl-test-helper.mjs} +0 -0
package/src/codex-capture.mjs
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
2
2
|
import { defaultCodexHome, findCodexThreadCandidate } from './codex-thread-index.mjs';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function codexSessionIdToThreadId(sessionId) {
|
|
18
|
-
if (!isCodexThroughlineSessionId(sessionId)) return null;
|
|
19
|
-
return sessionId.slice(CODEX_SESSION_PREFIX.length) || null;
|
|
20
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
CODEX_SESSION_PREFIX,
|
|
5
|
+
buildCodexThroughlineSessionId,
|
|
6
|
+
codexSessionIdToThreadId,
|
|
7
|
+
isCodexSessionId,
|
|
8
|
+
} from './hosts/identity.mjs';
|
|
9
|
+
|
|
10
|
+
// session identity の正本は hosts/identity.mjs。既存 import 面のため再 export する。
|
|
11
|
+
export {
|
|
12
|
+
CODEX_SESSION_PREFIX,
|
|
13
|
+
buildCodexThroughlineSessionId,
|
|
14
|
+
codexSessionIdToThreadId,
|
|
15
|
+
};
|
|
16
|
+
export const isCodexThroughlineSessionId = isCodexSessionId;
|
|
21
17
|
|
|
22
18
|
export function captureCodexRolloutToDb(
|
|
23
19
|
db,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
1
|
+
import { spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
2
2
|
|
|
3
3
|
export const CODEX_HANDOFF_MODEL_SMOKE_ENV = 'THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE';
|
|
4
4
|
export const DEFAULT_CODEX_HANDOFF_MODEL_SMOKE_TIMEOUT_MS = 120_000;
|
package/src/codex-sidecar.mjs
CHANGED
|
@@ -8,12 +8,12 @@ import {
|
|
|
8
8
|
rmSync,
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from 'node:fs';
|
|
11
|
-
import
|
|
12
|
-
import { homedir, platform as hostPlatform } from 'node:os';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
13
12
|
import { dirname, join } from 'node:path';
|
|
14
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
15
14
|
import { hashAuditorBody } from './body-digest.mjs';
|
|
16
15
|
import { normalizeProjectPathForCompare } from './project-path.mjs';
|
|
16
|
+
import { applyAndVerifyWindowsAcl, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
|
|
17
17
|
|
|
18
18
|
export const COMPLETED_TURN_RECEIPT_STORE_SCHEMA = 'throughline.completed_turn_receipts.v1';
|
|
19
19
|
export const COMPLETED_TURN_RECEIPT_SCHEMA_VERSION = '1.0';
|
|
@@ -21,7 +21,6 @@ export const COMPLETED_TURN_RECEIPT_LIMIT = 256;
|
|
|
21
21
|
|
|
22
22
|
const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.completed-turn-receipt-directory');
|
|
23
23
|
// CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
|
|
24
|
-
const WINDOWS_ACL_TIMEOUT_MS = 15_000;
|
|
25
24
|
|
|
26
25
|
export function defaultCompletedTurnReceiptStorePath(projectSha256, env = process.env) {
|
|
27
26
|
if (!isSha256(projectSha256)) throw new TypeError('projectSha256 must be a SHA-256 digest');
|
|
@@ -351,30 +350,4 @@ function assertExactOptions(options) {
|
|
|
351
350
|
throw new TypeError('completed turn receipt options are invalid');
|
|
352
351
|
}
|
|
353
352
|
}
|
|
354
|
-
function isWindows(env = process.env) { return env.OS === 'Windows_NT' || hostPlatform() === 'win32'; }
|
|
355
|
-
|
|
356
|
-
function applyAndVerifyWindowsAcl(path, directory) { runWindowsAclScript(path, directory, WINDOWS_ACL_APPLY_SCRIPT); }
|
|
357
|
-
function verifyWindowsAcl(path, directory) { runWindowsAclScript(path, directory, WINDOWS_ACL_VERIFY_SCRIPT); }
|
|
358
|
-
function runWindowsAclScript(path, directory, script) {
|
|
359
|
-
const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
360
|
-
env: { ...process.env, THROUGHLINE_ACL_PATH: path, THROUGHLINE_ACL_DIRECTORY: directory ? '1' : '0' },
|
|
361
|
-
stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
|
|
362
|
-
});
|
|
363
|
-
if (result.status !== 0) throw new Error('Windows owner-only ACL verification failed');
|
|
364
|
-
}
|
|
365
353
|
|
|
366
|
-
const WINDOWS_ACL_VERIFY_SCRIPT = String.raw`
|
|
367
|
-
$p=$env:THROUGHLINE_ACL_PATH; $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
368
|
-
$acl=if($isDir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)}
|
|
369
|
-
$owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
370
|
-
if($owner -ne $sid){exit 41}; $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if($rules.Count -ne 1){exit 42}
|
|
371
|
-
$r=$rules[0]; if($r.IdentityReference.Value -ne $sid -or $r.AccessControlType -ne 'Allow' -or $r.IsInherited -or ($r.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
|
|
372
|
-
`;
|
|
373
|
-
|
|
374
|
-
const WINDOWS_ACL_APPLY_SCRIPT = String.raw`
|
|
375
|
-
$p=$env:THROUGHLINE_ACL_PATH; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
376
|
-
$isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $acl=if($isDir){New-Object System.Security.AccessControl.DirectorySecurity}else{New-Object System.Security.AccessControl.FileSecurity}; $acl.SetAccessRuleProtection($true,$false)
|
|
377
|
-
$flags=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
|
|
378
|
-
$rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
|
|
379
|
-
$acl.SetOwner($sid); $acl.AddAccessRule($rule); if($isDir){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}
|
|
380
|
-
` + WINDOWS_ACL_VERIFY_SCRIPT;
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
writeCompletedTurnReceipt,
|
|
13
13
|
} from './completed-turn-receipts.mjs';
|
|
14
14
|
import { seedCompletedTurnReceiptStore } from './completed-turn-receipts-test-fixture.mjs';
|
|
15
|
-
import { verifyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
|
|
15
|
+
import { verifyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
|
|
16
16
|
|
|
17
17
|
function sandbox() {
|
|
18
18
|
const root = mkdtempSync(join(tmpdir(), 'throughline-completed-receipts-'));
|
|
@@ -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
|
+
});
|
package/src/haiku-summarizer.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
CODEX_SIDECAR_STATUS,
|
|
51
51
|
runCodexSidecarCommand,
|
|
52
52
|
} from './codex-sidecar.mjs';
|
|
53
|
-
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
53
|
+
import { spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
54
54
|
|
|
55
55
|
const MODEL = 'claude-haiku-4-5-20251001';
|
|
56
56
|
// Codex CLI 要約の既定。gpt-5.6-luna@low@1/5 は 2026-07-17 の実測評価で選定
|
package/src/handoff-record.mjs
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* persisted to DB; adapters can render it for Claude, Codex, or diagnostics.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { CODEX_SESSION_PREFIX } from './hosts/identity.mjs';
|
|
10
|
+
|
|
9
11
|
export const HANDOFF_RECORD_VERSION = 1;
|
|
10
12
|
export const N_RECENT_L2 = 20;
|
|
11
|
-
export
|
|
13
|
+
export { CODEX_SESSION_PREFIX };
|
|
12
14
|
|
|
13
15
|
const DEFAULT_INTENT = 'continue implementation';
|
|
14
16
|
const DEFAULT_CONSTRAINTS = [
|
|
@@ -74,65 +74,196 @@ test('hook modules can be imported without executing their hook body', () => {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test('Grok camelCase envelopes
|
|
77
|
+
test('Grok camelCase envelopes register a grok: session instead of no-op', () => {
|
|
78
78
|
const home = makeTempHome();
|
|
79
79
|
const project = makeTempProject();
|
|
80
|
+
const sessionId = '01a00aa2-50f8-7791-86fd-df2d27cf003e';
|
|
80
81
|
const common = {
|
|
81
|
-
sessionId
|
|
82
|
+
sessionId,
|
|
82
83
|
cwd: project,
|
|
83
84
|
workspaceRoot: project,
|
|
84
|
-
timestamp: '2026-08-
|
|
85
|
+
timestamp: '2026-08-17T00:00:00Z',
|
|
85
86
|
permissionMode: 'default',
|
|
86
87
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const start = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
91
|
+
home,
|
|
92
|
+
cwd: project,
|
|
93
|
+
input: JSON.stringify({ ...common, hookEventName: 'session_start', source: 'startup' }),
|
|
94
|
+
});
|
|
95
|
+
assert.equal(start.status, 0, start.stderr);
|
|
96
|
+
|
|
97
|
+
const prompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
98
|
+
home,
|
|
99
|
+
cwd: project,
|
|
100
|
+
input: JSON.stringify({ ...common, hookEventName: 'user_prompt_submit', prompt: 'hello grok' }),
|
|
101
|
+
});
|
|
102
|
+
assert.equal(prompt.status, 0, prompt.stderr);
|
|
103
|
+
|
|
104
|
+
const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
|
|
105
|
+
mkdirSync(historyDir, { recursive: true });
|
|
106
|
+
writeFileSync(
|
|
107
|
+
join(historyDir, 'chat_history.jsonl'),
|
|
108
|
+
[
|
|
109
|
+
JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
|
|
110
|
+
JSON.stringify({ type: 'assistant', content: 'captured reply' }),
|
|
111
|
+
].join('\n'),
|
|
112
|
+
);
|
|
113
|
+
const stop = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
|
|
114
|
+
home,
|
|
115
|
+
cwd: project,
|
|
116
|
+
input: JSON.stringify({
|
|
102
117
|
...common,
|
|
103
118
|
hookEventName: 'stop',
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
lastAssistantMessage: 'captured reply',
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
assert.equal(stop.status, 0, stop.stderr);
|
|
123
|
+
|
|
124
|
+
const db = openDb(home);
|
|
125
|
+
const row = db.prepare('SELECT session_id, project_path FROM sessions').get();
|
|
126
|
+
assert.equal(row.session_id, `grok:${sessionId}`);
|
|
127
|
+
assert.equal(row.project_path, project);
|
|
128
|
+
const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
bodies.map((b) => ({ role: b.role, text: b.text })),
|
|
131
|
+
[
|
|
132
|
+
{ role: 'assistant', text: 'captured reply' },
|
|
133
|
+
{ role: 'user', text: 'hello grok' },
|
|
134
|
+
],
|
|
135
|
+
);
|
|
136
|
+
db.close();
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(project, { recursive: true, force: true });
|
|
139
|
+
rmSync(home, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('Grok Stop with updates.jsonl transcriptPath still captures L2 from chat_history', () => {
|
|
144
|
+
const home = makeTempHome();
|
|
145
|
+
const project = makeTempProject();
|
|
146
|
+
const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
|
|
147
|
+
const common = {
|
|
148
|
+
sessionId,
|
|
149
|
+
cwd: project,
|
|
150
|
+
workspaceRoot: project,
|
|
151
|
+
timestamp: '2026-08-17T00:00:00Z',
|
|
152
|
+
permissionMode: 'default',
|
|
153
|
+
};
|
|
112
154
|
|
|
113
155
|
try {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
156
|
+
const start = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
157
|
+
home,
|
|
158
|
+
cwd: project,
|
|
159
|
+
input: JSON.stringify({ ...common, hookEventName: 'session_start', source: 'startup' }),
|
|
160
|
+
});
|
|
161
|
+
assert.equal(start.status, 0, start.stderr);
|
|
162
|
+
|
|
163
|
+
const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
|
|
164
|
+
mkdirSync(historyDir, { recursive: true });
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(historyDir, 'updates.jsonl'),
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
method: '_x.ai/session/update',
|
|
169
|
+
params: { update: { sessionUpdate: 'hook_execution', event_name: 'stop' } },
|
|
170
|
+
}) + '\n',
|
|
171
|
+
);
|
|
172
|
+
writeFileSync(
|
|
173
|
+
join(historyDir, 'chat_history.jsonl'),
|
|
174
|
+
[
|
|
175
|
+
JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
|
|
176
|
+
JSON.stringify({ type: 'assistant', content: 'captured reply' }),
|
|
177
|
+
].join('\n'),
|
|
178
|
+
);
|
|
179
|
+
const stop = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
|
|
180
|
+
home,
|
|
181
|
+
cwd: project,
|
|
182
|
+
input: JSON.stringify({
|
|
183
|
+
...common,
|
|
184
|
+
hookEventName: 'stop',
|
|
185
|
+
transcriptPath: join(historyDir, 'updates.jsonl'),
|
|
186
|
+
lastAssistantMessage: 'captured reply',
|
|
187
|
+
}),
|
|
122
188
|
});
|
|
189
|
+
assert.equal(stop.status, 0, stop.stderr);
|
|
123
190
|
|
|
191
|
+
const db = openDb(home);
|
|
192
|
+
const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
|
|
124
193
|
assert.deepEqual(
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
{
|
|
131
|
-
results: cases.map(({ event }) => ({ event, status: 0, stdout: '', stderr: '' })),
|
|
132
|
-
throughlineStateExists: false,
|
|
133
|
-
vscodeTaskExists: false,
|
|
134
|
-
},
|
|
194
|
+
bodies.map((b) => ({ role: b.role, text: b.text })),
|
|
195
|
+
[
|
|
196
|
+
{ role: 'assistant', text: 'captured reply' },
|
|
197
|
+
{ role: 'user', text: 'hello grok' },
|
|
198
|
+
],
|
|
135
199
|
);
|
|
200
|
+
db.close();
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(project, { recursive: true, force: true });
|
|
203
|
+
rmSync(home, { recursive: true, force: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('Grok wrapped /tl prompt writes a grok: baton', () => {
|
|
208
|
+
const home = makeTempHome();
|
|
209
|
+
const project = makeTempProject();
|
|
210
|
+
const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
|
|
211
|
+
try {
|
|
212
|
+
const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
213
|
+
home,
|
|
214
|
+
cwd: project,
|
|
215
|
+
input: JSON.stringify({
|
|
216
|
+
sessionId,
|
|
217
|
+
cwd: project,
|
|
218
|
+
hookEventName: 'user_prompt_submit',
|
|
219
|
+
prompt:
|
|
220
|
+
'<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>',
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
assert.equal(result.status, 0, result.stderr);
|
|
224
|
+
assert.match(result.stderr, /grok-continue exited/);
|
|
225
|
+
|
|
226
|
+
const db = openDb(home);
|
|
227
|
+
const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
|
|
228
|
+
assert.equal(row.project_path, project);
|
|
229
|
+
assert.equal(row.session_id, `grok:${sessionId}`);
|
|
230
|
+
db.close();
|
|
231
|
+
} finally {
|
|
232
|
+
rmSync(project, { recursive: true, force: true });
|
|
233
|
+
rmSync(home, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('Grok empty hook prompt still writes /tl baton from chat_history', () => {
|
|
238
|
+
const home = makeTempHome();
|
|
239
|
+
const project = makeTempProject();
|
|
240
|
+
const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
|
|
241
|
+
const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
|
|
242
|
+
mkdirSync(historyDir, { recursive: true });
|
|
243
|
+
writeFileSync(
|
|
244
|
+
join(historyDir, 'chat_history.jsonl'),
|
|
245
|
+
JSON.stringify({
|
|
246
|
+
type: 'user',
|
|
247
|
+
content: [{ type: 'text', text: '<user_query>\n/tl\n</user_query>\n<skill_information>x</skill_information>' }],
|
|
248
|
+
}) + '\n',
|
|
249
|
+
);
|
|
250
|
+
try {
|
|
251
|
+
const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
252
|
+
home,
|
|
253
|
+
cwd: project,
|
|
254
|
+
input: JSON.stringify({
|
|
255
|
+
sessionId,
|
|
256
|
+
cwd: project,
|
|
257
|
+
hookEventName: 'user_prompt_submit',
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
assert.equal(result.status, 0, result.stderr);
|
|
261
|
+
assert.match(result.stderr, /grok-continue exited/);
|
|
262
|
+
|
|
263
|
+
const db = openDb(home);
|
|
264
|
+
const row = db.prepare('SELECT session_id FROM handoff_batons').get();
|
|
265
|
+
assert.equal(row.session_id, `grok:${sessionId}`);
|
|
266
|
+
db.close();
|
|
136
267
|
} finally {
|
|
137
268
|
rmSync(project, { recursive: true, force: true });
|
|
138
269
|
rmSync(home, { recursive: true, force: true });
|
|
@@ -154,6 +285,7 @@ test('prompt-submit subprocess writes a /tl baton into an isolated DB', () => {
|
|
|
154
285
|
});
|
|
155
286
|
|
|
156
287
|
assert.equal(result.status, 0, result.stderr);
|
|
288
|
+
assert.equal(result.stderr.includes('grok-continue'), false);
|
|
157
289
|
|
|
158
290
|
const db = openDb(home);
|
|
159
291
|
const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
|
|
@@ -249,6 +381,87 @@ test('prompt-submit: non-baton prompt does not write any baton', () => {
|
|
|
249
381
|
}
|
|
250
382
|
});
|
|
251
383
|
|
|
384
|
+
test('Grok first prompt injects handoff into chat_history instead of stdout', () => {
|
|
385
|
+
const home = makeTempHome();
|
|
386
|
+
const project = makeTempProject();
|
|
387
|
+
const oldId = '01a00aa2-50f8-7791-86fd-df2d27cf003e';
|
|
388
|
+
const newId = '01a00ce5-0169-7852-95c0-9e6b8cc50c06';
|
|
389
|
+
const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), newId);
|
|
390
|
+
mkdirSync(historyDir, { recursive: true });
|
|
391
|
+
writeFileSync(
|
|
392
|
+
join(historyDir, 'chat_history.jsonl'),
|
|
393
|
+
[
|
|
394
|
+
JSON.stringify({ type: 'system', content: 'sys' }),
|
|
395
|
+
JSON.stringify({
|
|
396
|
+
type: 'user',
|
|
397
|
+
content: [{ type: 'text', text: '<user_query>\nこれかな?\n</user_query>' }],
|
|
398
|
+
}),
|
|
399
|
+
].join('\n') + '\n',
|
|
400
|
+
);
|
|
401
|
+
try {
|
|
402
|
+
const baton = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
403
|
+
home,
|
|
404
|
+
cwd: project,
|
|
405
|
+
input: JSON.stringify({
|
|
406
|
+
sessionId: oldId,
|
|
407
|
+
cwd: project,
|
|
408
|
+
hookEventName: 'user_prompt_submit',
|
|
409
|
+
prompt: '/tl',
|
|
410
|
+
}),
|
|
411
|
+
});
|
|
412
|
+
assert.equal(baton.status, 0, baton.stderr);
|
|
413
|
+
|
|
414
|
+
const db = openDb(home);
|
|
415
|
+
db.prepare(
|
|
416
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
417
|
+
VALUES (?, ?, 'active', 1, 1)`,
|
|
418
|
+
).run(`grok:${oldId}`, project);
|
|
419
|
+
db.prepare(
|
|
420
|
+
`INSERT INTO bodies
|
|
421
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
422
|
+
VALUES (?, ?, 1, 'assistant', 'old assistant body', 4, 2)`,
|
|
423
|
+
).run(`grok:${oldId}`, `grok:${oldId}`);
|
|
424
|
+
db.close();
|
|
425
|
+
|
|
426
|
+
const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
427
|
+
home,
|
|
428
|
+
cwd: project,
|
|
429
|
+
input: JSON.stringify({
|
|
430
|
+
sessionId: newId,
|
|
431
|
+
cwd: project,
|
|
432
|
+
hookEventName: 'session_start',
|
|
433
|
+
source: 'new',
|
|
434
|
+
}),
|
|
435
|
+
});
|
|
436
|
+
assert.equal(started.status, 0, started.stderr);
|
|
437
|
+
|
|
438
|
+
const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
|
|
439
|
+
home,
|
|
440
|
+
cwd: project,
|
|
441
|
+
input: JSON.stringify({
|
|
442
|
+
sessionId: newId,
|
|
443
|
+
cwd: project,
|
|
444
|
+
hookEventName: 'user_prompt_submit',
|
|
445
|
+
prompt: 'これかな?',
|
|
446
|
+
}),
|
|
447
|
+
});
|
|
448
|
+
assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
|
|
449
|
+
assert.equal(firstPrompt.stdout, '', 'Grok must not rely on ignored hook stdout');
|
|
450
|
+
|
|
451
|
+
const rows = readFileSync(join(historyDir, 'chat_history.jsonl'), 'utf8')
|
|
452
|
+
.split('\n')
|
|
453
|
+
.filter(Boolean)
|
|
454
|
+
.map((line) => JSON.parse(line));
|
|
455
|
+
assert.equal(rows[1].synthetic_reason, 'system_reminder');
|
|
456
|
+
assert.match(rows[1].content[0].text, /data-throughline-handoff="1"/);
|
|
457
|
+
assert.match(rows[1].content[0].text, /old assistant body/);
|
|
458
|
+
assert.match(rows[2].content[0].text, /これかな?/);
|
|
459
|
+
} finally {
|
|
460
|
+
rmSync(project, { recursive: true, force: true });
|
|
461
|
+
rmSync(home, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
252
465
|
test('two-phase: session-start registers intent only; first prompt consumes baton and injects', () => {
|
|
253
466
|
const home = makeTempHome();
|
|
254
467
|
const project = makeTempProject();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hosts/claude.mjs — Claude host adapter
|
|
3
|
+
*
|
|
4
|
+
* Claude は Throughline の基準 host。hook payload は snake_case のまま届き、
|
|
5
|
+
* 引き継ぎ注入は UserPromptSubmit hook の stdout でモデルへ渡る。
|
|
6
|
+
*/
|
|
7
|
+
import { CLAUDE_HOST, hostOfSessionId } from './identity.mjs';
|
|
8
|
+
|
|
9
|
+
export const claudeHostAdapter = Object.freeze({
|
|
10
|
+
host: CLAUDE_HOST,
|
|
11
|
+
matchesSessionId: (sessionId) => hostOfSessionId(sessionId) === CLAUDE_HOST,
|
|
12
|
+
// Claude Stop payload の last_assistant_message を transcript 可視化の
|
|
13
|
+
// barrier に使う (ADR 0012)。
|
|
14
|
+
waitsForStopTranscriptFlush: true,
|
|
15
|
+
// Claude は UserPromptSubmit stdout がそのままモデル可視 context になる。
|
|
16
|
+
deliverHandoffInjection({ text, stdout = process.stdout }) {
|
|
17
|
+
stdout.write(text + '\n');
|
|
18
|
+
return { delivered: true };
|
|
19
|
+
},
|
|
20
|
+
// Claude の prompt は裸の slash command がそのまま届く。
|
|
21
|
+
resolveCommandPrompt({ prompt }) {
|
|
22
|
+
return prompt;
|
|
23
|
+
},
|
|
24
|
+
afterBatonWrite() {
|
|
25
|
+
return { launched: false };
|
|
26
|
+
},
|
|
27
|
+
});
|