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.
Files changed (59) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.ja.md +40 -2
  3. package/README.md +42 -3
  4. package/bin/throughline.mjs +12 -1
  5. package/docs/00_overview.md +2 -0
  6. package/docs/02_clear_auto_handoff_plan.md +6 -0
  7. package/docs/04_public_release_plan.md +2 -2
  8. package/docs/adr/0021-grok-host-capture.md +60 -0
  9. package/docs/plan_grok-successor-launch.md +99 -0
  10. package/package.json +4 -2
  11. package/src/auditor-context.mjs +2 -1
  12. package/src/cli/codex-handoff-start.mjs +5 -23
  13. package/src/cli/codex-summarize.mjs +2 -1
  14. package/src/cli/codex-visibility-smoke.mjs +5 -4
  15. package/src/cli/grok-continue.mjs +184 -0
  16. package/src/cli/grok-continue.test.mjs +239 -0
  17. package/src/cli/handoff-context.mjs +19 -0
  18. package/src/cli/handoff-context.test.mjs +13 -0
  19. package/src/cli/install.mjs +62 -0
  20. package/src/cli/install.test.mjs +46 -0
  21. package/src/cli/runtime-errors.test.mjs +1 -1
  22. package/src/codex-capture.mjs +14 -18
  23. package/src/codex-handoff-model-smoke.mjs +1 -1
  24. package/src/codex-sidecar.mjs +1 -1
  25. package/src/completed-turn-receipts.mjs +2 -29
  26. package/src/completed-turn-receipts.test.mjs +1 -1
  27. package/src/grok-history-inject.mjs +71 -0
  28. package/src/grok-history-inject.test.mjs +69 -0
  29. package/src/haiku-summarizer.mjs +1 -1
  30. package/src/handoff-record.mjs +3 -1
  31. package/src/hook-entrypoints.test.mjs +257 -44
  32. package/src/hosts/claude.mjs +27 -0
  33. package/src/hosts/codex.mjs +28 -0
  34. package/src/hosts/grok.mjs +96 -0
  35. package/src/hosts/grok.test.mjs +79 -0
  36. package/src/hosts/identity.mjs +71 -0
  37. package/src/hosts/identity.test.mjs +65 -0
  38. package/src/hosts/index.mjs +37 -0
  39. package/src/os/macos-terminal.mjs +44 -0
  40. package/src/os/open-url.mjs +16 -0
  41. package/src/os/paths.mjs +12 -0
  42. package/src/os/shell.mjs +16 -0
  43. package/src/os/windows-acl.mjs +57 -0
  44. package/src/project-path.mjs +4 -4
  45. package/src/prompt-submit.mjs +53 -18
  46. package/src/prompt-submit.test.mjs +75 -1
  47. package/src/runtime-error-hook.test.mjs +1 -1
  48. package/src/runtime-error-store.mjs +3 -40
  49. package/src/runtime-error-store.test.mjs +1 -1
  50. package/src/session-start.mjs +13 -5
  51. package/src/state-file.mjs +6 -5
  52. package/src/token-monitor.mjs +7 -6
  53. package/src/transcript-reader-grok.test.mjs +37 -0
  54. package/src/transcript-reader.mjs +7 -3
  55. package/src/turn-processor.mjs +8 -7
  56. package/src/hook-envelope.mjs +0 -12
  57. /package/src/{portable-spawn-sync.mjs → os/portable-spawn-sync.mjs} +0 -0
  58. /package/src/{portable-spawn-sync.test.mjs → os/portable-spawn-sync.test.mjs} +0 -0
  59. /package/src/{windows-acl-test-helper.mjs → os/windows-acl-test-helper.mjs} +0 -0
@@ -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
- export const CODEX_SESSION_PREFIX = 'codex:';
5
-
6
- export function buildCodexThroughlineSessionId(threadId) {
7
- if (typeof threadId !== 'string' || threadId.trim().length === 0) {
8
- throw new Error('threadId is required');
9
- }
10
- return `${CODEX_SESSION_PREFIX}${threadId.trim()}`;
11
- }
12
-
13
- export function isCodexThroughlineSessionId(sessionId) {
14
- return typeof sessionId === 'string' && sessionId.startsWith(CODEX_SESSION_PREFIX);
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;
@@ -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_SIDECAR_WORKFLOWS = Object.freeze([
4
4
  'review',
@@ -8,12 +8,12 @@ import {
8
8
  rmSync,
9
9
  writeFileSync,
10
10
  } from 'node:fs';
11
- import childProcess from 'node:child_process';
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
+ });
@@ -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 の実測評価で選定
@@ -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 const CODEX_SESSION_PREFIX = 'codex:';
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 are unsupported no-op before Throughline side effects [GF04T]', () => {
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: 'grok-session',
82
+ sessionId,
82
83
  cwd: project,
83
84
  workspaceRoot: project,
84
- timestamp: '2026-08-14T00:00:00Z',
85
+ timestamp: '2026-08-17T00:00:00Z',
85
86
  permissionMode: 'default',
86
87
  };
87
- const cases = [
88
- {
89
- event: 'SessionStart',
90
- entrypoint: 'src/session-start.mjs',
91
- input: { ...common, hookEventName: 'session_start', source: 'startup' },
92
- },
93
- {
94
- event: 'UserPromptSubmit',
95
- entrypoint: 'src/prompt-submit.mjs',
96
- input: { ...common, hookEventName: 'user_prompt_submit', prompt: 'hello' },
97
- },
98
- {
99
- event: 'Stop',
100
- entrypoint: 'src/turn-processor.mjs',
101
- input: {
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
- reason: 'end_turn',
105
- stopHookActive: false,
106
- lastAssistantMessage: 'done',
107
- backgroundTasks: [],
108
- sessionCrons: [],
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 results = cases.map(({ event, entrypoint, input }) => {
115
- const result = runNode([join(REPO_ROOT, entrypoint)], {
116
- home,
117
- cwd: project,
118
- input: JSON.stringify(input),
119
- env: { THROUGHLINE_NO_VSCODE: '0', TERM_PROGRAM: 'vscode' },
120
- });
121
- return { event, status: result.status, stdout: result.stdout, stderr: result.stderr };
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
- results,
127
- throughlineStateExists: existsSync(join(home, '.throughline')),
128
- vscodeTaskExists: existsSync(join(project, '.vscode', 'tasks.json')),
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
+ });