throughline 0.10.8 → 0.10.10
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 +23 -1
- package/README.md +4 -2
- package/docs/04_public_release_plan.md +2 -1
- package/docs/adr/0021-grok-host-capture.md +4 -2
- package/package.json +1 -1
- package/src/cli/handoff-context.mjs +6 -6
- package/src/cli/handoff-context.test.mjs +34 -0
- package/src/cli/latest-session.mjs +2 -2
- package/src/cli/latest-session.test.mjs +36 -2
- package/src/cli/self-update.mjs +3 -3
- package/src/cli/self-update.test.mjs +5 -0
- package/src/completed-turn-receipts.test.mjs +1 -1
- package/src/db.mjs +12 -1
- package/src/docs-contract.test.mjs +2 -2
- package/src/hosts/grok.mjs +13 -12
- package/src/hosts/grok.test.mjs +37 -0
- package/src/hosts/index.mjs +3 -0
- package/src/os/portable-spawn-sync.mjs +1 -1
- package/src/os/windows-acl-test-helper.mjs +4 -4
- package/src/os/windows-acl.mjs +3 -3
- package/src/prompt-submit.mjs +1 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +1 -1
- package/src/test-env.mjs +12 -1
- package/src/turn-processor.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,26 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.10.10] — 2026-09-01
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Grok 1.0.13がcamelCaseとsnake_caseのhook属性を同時に送る場合でも、Grokの
|
|
18
|
+
予約環境変数をhost境界として`grok:` sessionへ正規化する。Stop hookをCursorと
|
|
19
|
+
誤認して同じBotに`cursor:` sessionを作る問題を修正した。
|
|
20
|
+
- `latest-session`と`handoff-context`のread-only SQLite接続にも、書込接続と同じ
|
|
21
|
+
5秒の競合待ちを適用した。hook書込と再起動時の記憶読取が重なっても、即時失敗しない。
|
|
22
|
+
|
|
23
|
+
## [0.10.9] — 2026-09-01
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- `handoff-context --supplement-file`は、capture済みsessionに会話本文がまだ無い場合でも、
|
|
28
|
+
同じprojectに束縛された補足記憶だけを9,500字予算内で返す。Cursorの初回Workspace
|
|
29
|
+
Trust直後など、session行だけが先に作られたBotも記憶付きで再起動できる。
|
|
30
|
+
- Windowsの子プロセスとowner-only ACL処理を、工場標準のPowerShell 7へ統一した。
|
|
31
|
+
製品CIも現行の3環境(macOS・Linux workstation・Windows native)を直接検証する。
|
|
32
|
+
|
|
13
33
|
## [0.10.8] — 2026-09-01
|
|
14
34
|
|
|
15
35
|
### Added
|
|
@@ -1387,7 +1407,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1387
1407
|
|
|
1388
1408
|
---
|
|
1389
1409
|
|
|
1390
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.
|
|
1410
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.10...HEAD
|
|
1411
|
+
[0.10.10]: https://github.com/kitepon/Throughline/compare/v0.10.9...v0.10.10
|
|
1412
|
+
[0.10.9]: https://github.com/kitepon/Throughline/compare/v0.10.8...v0.10.9
|
|
1391
1413
|
[0.10.8]: https://github.com/kitepon/Throughline/compare/v0.10.7...v0.10.8
|
|
1392
1414
|
[0.10.7]: https://github.com/kitepon/Throughline/compare/v0.10.6...v0.10.7
|
|
1393
1415
|
[0.10.6]: https://github.com/kitepon/Throughline/compare/v0.10.5...v0.10.6
|
package/README.md
CHANGED
|
@@ -817,7 +817,7 @@ entry to the `tasks` array yourself:
|
|
|
817
817
|
|
|
818
818
|
## Commands
|
|
819
819
|
|
|
820
|
-
**The current release is v0.10.
|
|
820
|
+
**The current release is v0.10.10.** Throughline supports Claude Code, Codex,
|
|
821
821
|
Grok, and Cursor as documented host adapters. The versioned, read-only
|
|
822
822
|
`handoff-context` boundary opens only an existing database and leaves baton
|
|
823
823
|
state, session ownership, and memory rows unchanged. Factory diagnostics,
|
|
@@ -924,7 +924,9 @@ inheritance text used by SessionStart. A launcher may append
|
|
|
924
924
|
```
|
|
925
925
|
|
|
926
926
|
The supplement is included only when `projectPath` matches the source session
|
|
927
|
-
and shares the 9,500-character budget with conversation memory.
|
|
927
|
+
and shares the 9,500-character budget with conversation memory. If the captured
|
|
928
|
+
session has no dialogue context yet, a valid project-bound supplement is returned
|
|
929
|
+
by itself. The command does not create or migrate a
|
|
928
930
|
database, consume a baton, merge sessions, infer a latest session, change
|
|
929
931
|
`sessions.merged_into`, or reassign L1/L2/L3 rows. AIterm uses this boundary for
|
|
930
932
|
its optional cross-harness portable fork; the Observer feed is a separate
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 現行状態
|
|
8
8
|
|
|
9
|
-
- release candidateのpackage版は `0.10.
|
|
9
|
+
- release candidateのpackage版は `0.10.10`。tagとnpmの公開確認はpublish後に行う。
|
|
10
10
|
- Claude Code、Codex、Grok、Cursorをfirst-class hostとして扱う。
|
|
11
11
|
- 現行DB schemaはv9。schemaの正本は [src/db.mjs](../src/db.mjs) の
|
|
12
12
|
`CURRENT_VERSION`、二相handoffの判断は
|
|
@@ -77,6 +77,7 @@ publish済みでも、上の確認が終わるまではrelease完了としない
|
|
|
77
77
|
- Codex: UserPromptSubmit / PostToolUse / Stop hook、feature flags、`$throughline` skill。
|
|
78
78
|
絶対Node + installed CLI pathを使う。
|
|
79
79
|
- Grok: `~/.grok/hooks/throughline.json`と`grok-continue`。絶対pathを使う。
|
|
80
|
+
Grok予約環境変数でhostを確定し、camelCase/snake_caseの属性名を同じ境界で正規化する。
|
|
80
81
|
- Cursor: `~/.cursor/hooks.json`へsessionStart / beforeSubmitPrompt / stopをupsertし、
|
|
81
82
|
既存hookを保持する。注入は`additional_context`を使う。
|
|
82
83
|
|
|
@@ -14,8 +14,10 @@ Grok stores turns in `~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/cha
|
|
|
14
14
|
|
|
15
15
|
## Decision
|
|
16
16
|
|
|
17
|
-
-
|
|
18
|
-
-
|
|
17
|
+
- Grokの予約環境変数(`GROK_HOOK_EVENT` / `GROK_SESSION_ID`)をhost境界とする。
|
|
18
|
+
- camelCase (`sessionId` / `hookEventName`) とsnake_case (`session_id` /
|
|
19
|
+
`hook_event_name`) のどちらも既存hook契約へ正規化し、idへ`grok:`を付ける。
|
|
20
|
+
Grok 1.0.13のように両方が同居してもCursorとして分類しない。
|
|
19
21
|
- Read L2 from Grok `chat_history.jsonl`. Do not invent a Claude-shaped transcript.
|
|
20
22
|
- Grok host ignores payload `transcriptPath`. Live Stop sends `updates.jsonl`, which has no user/assistant rows.
|
|
21
23
|
- Install product hooks at `~/.grok/hooks/throughline.json`. Do not write factory.json.
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
2
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
3
|
import { isAbsolute, join } from 'node:path';
|
|
5
4
|
|
|
6
5
|
import { sameProjectPath } from '../project-path.mjs';
|
|
6
|
+
import { openReadOnlyDb } from '../db.mjs';
|
|
7
7
|
import {
|
|
8
8
|
buildBudgetedResumeContext,
|
|
9
9
|
INJECTION_BUDGET_CHARS,
|
|
@@ -62,7 +62,7 @@ export function readHandoffContext(sessionId, {
|
|
|
62
62
|
} = {}) {
|
|
63
63
|
if (!existsSync(dbPath)) return null;
|
|
64
64
|
|
|
65
|
-
const db =
|
|
65
|
+
const db = openReadOnlyDb(dbPath);
|
|
66
66
|
try {
|
|
67
67
|
let supplementContext = null;
|
|
68
68
|
if (supplementFile) {
|
|
@@ -82,11 +82,11 @@ export function readHandoffContext(sessionId, {
|
|
|
82
82
|
isInheritance: true,
|
|
83
83
|
maxChars: INJECTION_BUDGET_CHARS - (supplementContext?.length ?? 0) - separator.length,
|
|
84
84
|
})?.text ?? null;
|
|
85
|
-
if (!context) return null;
|
|
85
|
+
if (!context && !supplementContext) return null;
|
|
86
86
|
|
|
87
|
-
const combined = supplementContext
|
|
87
|
+
const combined = supplementContext && context
|
|
88
88
|
? `${supplementContext}${separator}${context}`
|
|
89
|
-
: context;
|
|
89
|
+
: supplementContext ?? context;
|
|
90
90
|
if (combined.length > INJECTION_BUDGET_CHARS) {
|
|
91
91
|
throw new RangeError('handoff context exceeds injection budget');
|
|
92
92
|
}
|
|
@@ -101,7 +101,7 @@ export function readSessionProjectPath(sessionId, {
|
|
|
101
101
|
} = {}) {
|
|
102
102
|
if (!existsSync(dbPath)) return null;
|
|
103
103
|
|
|
104
|
-
const db =
|
|
104
|
+
const db = openReadOnlyDb(dbPath);
|
|
105
105
|
try {
|
|
106
106
|
const row = db.prepare(
|
|
107
107
|
'SELECT project_path FROM sessions WHERE session_id = ?',
|
|
@@ -169,6 +169,40 @@ test('handoff-context adds a project-bound supplement inside the shared budget',
|
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
test('handoff-context returns a project-bound supplement when the captured session has no dialogue yet', () => {
|
|
173
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-only-'));
|
|
174
|
+
try {
|
|
175
|
+
const { db, dbPath } = createFixture(home);
|
|
176
|
+
db.exec('DELETE FROM details; DELETE FROM bodies; DELETE FROM skeletons;');
|
|
177
|
+
const before = ownershipSnapshot(db);
|
|
178
|
+
db.close();
|
|
179
|
+
const supplementFile = join(home, 'supplement.json');
|
|
180
|
+
writeFileSync(supplementFile, JSON.stringify({
|
|
181
|
+
schema: 'throughline.handoff_supplement.v1',
|
|
182
|
+
projectPath: '/work/project',
|
|
183
|
+
sections: [{ title: 'Botプロフィール', content: '名前はCursor確認担当' }],
|
|
184
|
+
}));
|
|
185
|
+
|
|
186
|
+
const result = runCli(home, [
|
|
187
|
+
'handoff-context', '--session', SESSION_ID, '--json',
|
|
188
|
+
'--supplement-file', supplementFile,
|
|
189
|
+
]);
|
|
190
|
+
assert.equal(result.status, 0, result.stderr);
|
|
191
|
+
assert.deepEqual(JSON.parse(result.stdout), {
|
|
192
|
+
schema: 'throughline.handoff_context.v1',
|
|
193
|
+
status: 'ready',
|
|
194
|
+
sessionId: SESSION_ID,
|
|
195
|
+
context: '## このBotの長期記憶と関連知識\n\n### Botプロフィール\n名前はCursor確認担当',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const verify = new DatabaseSync(dbPath, { readOnly: true });
|
|
199
|
+
assert.deepEqual(ownershipSnapshot(verify), before);
|
|
200
|
+
verify.close();
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(home, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
172
206
|
test('handoff-context refuses a supplement from another bot project', () => {
|
|
173
207
|
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-scope-'));
|
|
174
208
|
try {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
3
|
import { join, resolve } from 'node:path';
|
|
5
4
|
|
|
5
|
+
import { openReadOnlyDb } from '../db.mjs';
|
|
6
6
|
import { sameProjectPath } from '../project-path.mjs';
|
|
7
7
|
|
|
8
8
|
export const LATEST_SESSION_SCHEMA = 'throughline.latest_session.v1';
|
|
@@ -25,7 +25,7 @@ export function findLatestSession(projectPath, {
|
|
|
25
25
|
} = {}) {
|
|
26
26
|
if (!existsSync(dbPath)) return null;
|
|
27
27
|
|
|
28
|
-
const db =
|
|
28
|
+
const db = openReadOnlyDb(dbPath);
|
|
29
29
|
try {
|
|
30
30
|
const rows = db.prepare(
|
|
31
31
|
`SELECT session_id, project_path, updated_at
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
2
4
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
5
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
6
8
|
import test from 'node:test';
|
|
7
9
|
|
|
@@ -38,6 +40,38 @@ test('同じprojectの最新sessionだけを返す', () => {
|
|
|
38
40
|
}
|
|
39
41
|
});
|
|
40
42
|
|
|
43
|
+
test('別hook processの短いwriter lockを待って同じprojectを読む', async () => {
|
|
44
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-latest-session-lock-'));
|
|
45
|
+
const dbPath = join(root, 'throughline.db');
|
|
46
|
+
const project = join(root, 'bot');
|
|
47
|
+
const db = new DatabaseSync(dbPath);
|
|
48
|
+
db.exec('PRAGMA journal_mode=DELETE; CREATE TABLE sessions (session_id TEXT, project_path TEXT, updated_at TEXT)');
|
|
49
|
+
db.prepare('INSERT INTO sessions VALUES (?, ?, ?)').run('same', project, '2026-09-01T02:00:00Z');
|
|
50
|
+
db.close();
|
|
51
|
+
|
|
52
|
+
const holder = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
53
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
54
|
+
const db = new DatabaseSync(process.argv[1]);
|
|
55
|
+
db.exec('BEGIN EXCLUSIVE');
|
|
56
|
+
process.stdout.write('locked\\n');
|
|
57
|
+
setTimeout(() => { db.exec('COMMIT'); db.close(); }, 200);
|
|
58
|
+
`, dbPath], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
59
|
+
const holderExit = once(holder, 'exit');
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
holder.stdout.once('data', resolve);
|
|
62
|
+
holder.once('error', reject);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
assert.equal(findLatestSession(project, { dbPath }).session_id, 'same');
|
|
67
|
+
const [code] = await holderExit;
|
|
68
|
+
assert.equal(code, 0);
|
|
69
|
+
} finally {
|
|
70
|
+
if (holder.exitCode === null) holder.kill();
|
|
71
|
+
rmSync(root, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
41
75
|
test('readyとemptyをJSONで返す', () => {
|
|
42
76
|
const stdout = output();
|
|
43
77
|
const stderr = output();
|
|
@@ -49,7 +83,7 @@ test('readyとemptyをJSONで返す', () => {
|
|
|
49
83
|
assert.deepEqual(JSON.parse(stdout.read()), {
|
|
50
84
|
schema: 'throughline.latest_session.v1',
|
|
51
85
|
status: 'ready',
|
|
52
|
-
projectPath: '/bots/a',
|
|
86
|
+
projectPath: resolve('/bots/a'),
|
|
53
87
|
sessionId: 'claude:abc',
|
|
54
88
|
updatedAt: 'now',
|
|
55
89
|
});
|
package/src/cli/self-update.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { realpathSync } from 'node:fs';
|
|
3
|
-
import { dirname,
|
|
3
|
+
import { dirname, join, posix, win32 } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
6
|
import { spawnPortableSync } from '../os/portable-spawn-sync.mjs';
|
|
@@ -94,7 +94,7 @@ export function resolveGlobalCliPath(result, platform = process.platform) {
|
|
|
94
94
|
if (!succeeded(result) || typeof result.stdout !== 'string') return null;
|
|
95
95
|
const lines = result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
96
96
|
if (lines.length !== 1) return null;
|
|
97
|
-
const path = platform === 'win32' ? win32 :
|
|
97
|
+
const path = platform === 'win32' ? win32 : posix;
|
|
98
98
|
if (!path.isAbsolute(lines[0])) return null;
|
|
99
99
|
return path.join(lines[0], 'throughline', 'bin', 'throughline.mjs');
|
|
100
100
|
}
|
|
@@ -110,7 +110,7 @@ export function validateSelfUpdateIdentity(value, {
|
|
|
110
110
|
if (Object.keys(value).sort().join('\0') !== exact.sort().join('\0')) return false;
|
|
111
111
|
if (value.schema !== SELF_UPDATE_IDENTITY_SCHEMA || value.version !== version ||
|
|
112
112
|
typeof value.cliPath !== 'string') return false;
|
|
113
|
-
const path = platform === 'win32' ? win32 :
|
|
113
|
+
const path = platform === 'win32' ? win32 : posix;
|
|
114
114
|
if (!path.isAbsolute(value.cliPath) || !path.isAbsolute(cliPath ?? '')) return false;
|
|
115
115
|
try {
|
|
116
116
|
const actual = canonicalizePath(value.cliPath);
|
|
@@ -118,6 +118,7 @@ test('self-update delegates post-install work to the newly installed CLI', () =>
|
|
|
118
118
|
env: { PATH: '/fixture/bin' },
|
|
119
119
|
runner,
|
|
120
120
|
packageVersion: '0.10.4',
|
|
121
|
+
platform: 'linux',
|
|
121
122
|
nodePath: '/node',
|
|
122
123
|
cliPath: '/old-throughline.mjs',
|
|
123
124
|
canonicalizePath: fakeCanonicalizePath,
|
|
@@ -160,6 +161,7 @@ test('new CLI completes install, migration, and diagnostics in order', () => {
|
|
|
160
161
|
},
|
|
161
162
|
runner,
|
|
162
163
|
packageVersion: '0.10.5',
|
|
164
|
+
platform: 'linux',
|
|
163
165
|
nodePath: '/node',
|
|
164
166
|
cliPath: '/throughline.mjs',
|
|
165
167
|
}), 0);
|
|
@@ -257,6 +259,7 @@ test('old CLI help with exit 0 cannot satisfy the post-install protocol', () =>
|
|
|
257
259
|
stderr: sink(),
|
|
258
260
|
runner,
|
|
259
261
|
packageVersion: '0.10.4',
|
|
262
|
+
platform: 'linux',
|
|
260
263
|
nodePath: '/node',
|
|
261
264
|
canonicalizePath: fakeCanonicalizePath,
|
|
262
265
|
}), 1);
|
|
@@ -293,6 +296,7 @@ test('post-install non-JSON failure preserves child stderr as the cause', () =>
|
|
|
293
296
|
stderr,
|
|
294
297
|
runner,
|
|
295
298
|
packageVersion: '0.10.4',
|
|
299
|
+
platform: 'linux',
|
|
296
300
|
nodePath: '/node',
|
|
297
301
|
canonicalizePath: fakeCanonicalizePath,
|
|
298
302
|
}), 1);
|
|
@@ -330,6 +334,7 @@ test('post-install structured failure preserves its stage and stderr', () => {
|
|
|
330
334
|
stderr,
|
|
331
335
|
runner,
|
|
332
336
|
packageVersion: '0.10.4',
|
|
337
|
+
platform: 'linux',
|
|
333
338
|
nodePath: '/node',
|
|
334
339
|
canonicalizePath: fakeCanonicalizePath,
|
|
335
340
|
}), 7);
|
|
@@ -148,7 +148,7 @@ test('completed turn receipt: one Windows mutation spends ACL processes only on
|
|
|
148
148
|
try {
|
|
149
149
|
writeCompletedTurnReceipt(input(1), options);
|
|
150
150
|
assert.equal(calls.length, 3, 'new directory, lock, and store each require one apply+verify process');
|
|
151
|
-
assert.ok(calls.every((call) => call.command === '
|
|
151
|
+
assert.ok(calls.every((call) => call.command === 'pwsh.exe'));
|
|
152
152
|
calls.length = 0;
|
|
153
153
|
|
|
154
154
|
writeCompletedTurnReceipt(input(2), options);
|
package/src/db.mjs
CHANGED
|
@@ -9,11 +9,22 @@ import { join } from 'path';
|
|
|
9
9
|
|
|
10
10
|
const DB_DIR = join(homedir(), '.throughline');
|
|
11
11
|
const DB_PATH = join(DB_DIR, 'throughline.db');
|
|
12
|
-
const DB_BUSY_TIMEOUT_MS = 5_000;
|
|
12
|
+
export const DB_BUSY_TIMEOUT_MS = 5_000;
|
|
13
13
|
export const CURRENT_VERSION = 9;
|
|
14
14
|
|
|
15
15
|
let _db = null;
|
|
16
16
|
|
|
17
|
+
export function openReadOnlyDb(path = DB_PATH) {
|
|
18
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
19
|
+
try {
|
|
20
|
+
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
21
|
+
return db;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
db.close();
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
function initSchema(db) {
|
|
18
29
|
const row = db.prepare('PRAGMA user_version').get();
|
|
19
30
|
const version = row.user_version ?? 0;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
3
|
import { test } from 'node:test';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
5
|
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
extractMarkdownTargets,
|
|
9
8
|
findMissingPackedTargets,
|
|
10
9
|
} from '../scripts/verify-pack-markdown-links.mjs';
|
|
10
|
+
import { spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
11
11
|
|
|
12
12
|
test('Markdown-only CI runs the product-owned documentation contract', () => {
|
|
13
13
|
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
@@ -36,7 +36,7 @@ test('Markdown-only CI runs the product-owned documentation contract', () => {
|
|
|
36
36
|
);
|
|
37
37
|
|
|
38
38
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
39
|
-
const result =
|
|
39
|
+
const result = spawnPortableSync(npmCommand, ['run', 'verify:docs', '--silent'], {
|
|
40
40
|
cwd: fileURLToPath(new URL('..', import.meta.url)),
|
|
41
41
|
encoding: 'utf8',
|
|
42
42
|
});
|
package/src/hosts/grok.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* hosts/grok.mjs — Grok host 境界 (envelope 正規化 + hook adapter)
|
|
3
3
|
*
|
|
4
|
-
* Grok sends Claude-compatible hook commands
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Grok sends Claude-compatible hook commands and may expose both camelCase
|
|
5
|
+
* and snake_case aliases. The reserved GROK_* hook environment binds the
|
|
6
|
+
* host; Throughline normalizes either wire to the Claude snake_case contract
|
|
7
|
+
* and prefixes session ids so they never mix with other host identities.
|
|
8
8
|
*
|
|
9
9
|
* Grok 固有の挙動 (v0.10.0 / ADR 0021):
|
|
10
10
|
* - UserPromptSubmit stdout はモデルへ渡らないため、引き継ぎ注入は
|
|
@@ -30,8 +30,7 @@ export function isGrokEnvelope(payload) {
|
|
|
30
30
|
&& typeof payload.sessionId === 'string'
|
|
31
31
|
&& payload.sessionId.length > 0
|
|
32
32
|
&& typeof payload.hookEventName === 'string'
|
|
33
|
-
&& payload.hookEventName.length > 0
|
|
34
|
-
&& !Object.hasOwn(payload, 'session_id');
|
|
33
|
+
&& payload.hookEventName.length > 0;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
export function deriveGrokChatHistoryPath(projectPath, sessionId, { home = homedir() } = {}) {
|
|
@@ -40,21 +39,23 @@ export function deriveGrokChatHistoryPath(projectPath, sessionId, { home = homed
|
|
|
40
39
|
return join(home, '.grok', 'sessions', encodeURIComponent(projectPath), bare, 'chat_history.jsonl');
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
export function normalizeGrokHookPayload(payload, { home = homedir() } = {}) {
|
|
44
|
-
if (!isGrokEnvelope(payload)) return payload;
|
|
42
|
+
export function normalizeGrokHookPayload(payload, { home = homedir(), force = false, env = {} } = {}) {
|
|
43
|
+
if (!force && !isGrokEnvelope(payload)) return payload;
|
|
44
|
+
const rawSessionId = payload.sessionId ?? payload.session_id ?? env.GROK_SESSION_ID;
|
|
45
|
+
const hookEventName = payload.hookEventName ?? payload.hook_event_name ?? env.GROK_HOOK_EVENT;
|
|
45
46
|
const cwd = typeof payload.cwd === 'string' && payload.cwd.length > 0
|
|
46
47
|
? payload.cwd
|
|
47
|
-
: (typeof payload.workspaceRoot === 'string' ? payload.workspaceRoot :
|
|
48
|
+
: (typeof payload.workspaceRoot === 'string' ? payload.workspaceRoot : env.GROK_WORKSPACE_ROOT);
|
|
48
49
|
// Live Grok Stop sets transcriptPath to updates.jsonl (sessionUpdate frames,
|
|
49
50
|
// no user/assistant rows). L2 lives in chat_history.jsonl only.
|
|
50
|
-
const transcriptPath = deriveGrokChatHistoryPath(cwd,
|
|
51
|
+
const transcriptPath = deriveGrokChatHistoryPath(cwd, rawSessionId, { home });
|
|
51
52
|
return {
|
|
52
53
|
...payload,
|
|
53
|
-
session_id: `${GROK_SESSION_PREFIX}${
|
|
54
|
+
session_id: `${GROK_SESSION_PREFIX}${rawSessionId}`,
|
|
54
55
|
cwd,
|
|
55
56
|
source: payload.source,
|
|
56
57
|
prompt: payload.prompt,
|
|
57
|
-
hook_event_name:
|
|
58
|
+
hook_event_name: hookEventName,
|
|
58
59
|
transcript_path: transcriptPath,
|
|
59
60
|
last_assistant_message: payload.lastAssistantMessage,
|
|
60
61
|
};
|
package/src/hosts/grok.test.mjs
CHANGED
|
@@ -17,6 +17,15 @@ test('isGrokEnvelope detects camelCase wire without session_id', () => {
|
|
|
17
17
|
}),
|
|
18
18
|
true,
|
|
19
19
|
);
|
|
20
|
+
assert.equal(
|
|
21
|
+
isGrokEnvelope({
|
|
22
|
+
sessionId: '01a00aa2-dead-beef',
|
|
23
|
+
session_id: '01a00aa2-dead-beef',
|
|
24
|
+
hookEventName: 'stop',
|
|
25
|
+
hook_event_name: 'stop',
|
|
26
|
+
}),
|
|
27
|
+
true,
|
|
28
|
+
);
|
|
20
29
|
assert.equal(
|
|
21
30
|
isGrokEnvelope({
|
|
22
31
|
session_id: 'claude-session',
|
|
@@ -26,6 +35,34 @@ test('isGrokEnvelope detects camelCase wire without session_id', () => {
|
|
|
26
35
|
);
|
|
27
36
|
});
|
|
28
37
|
|
|
38
|
+
test('Grok予約環境がsnake_case payloadをCursorではなくGrokへ固定する', () => {
|
|
39
|
+
const home = '/tmp/tl-home';
|
|
40
|
+
const cwd = '/srv/bellteam/bots/bot-a6fbf921';
|
|
41
|
+
const sessionId = '15e10cc9-ba86-4056-874b-0d1724b1bed3';
|
|
42
|
+
const payload = normalizeHookPayload(
|
|
43
|
+
{
|
|
44
|
+
session_id: sessionId,
|
|
45
|
+
hook_event_name: 'stop',
|
|
46
|
+
cwd,
|
|
47
|
+
last_assistant_message: 'done',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
home,
|
|
51
|
+
env: {
|
|
52
|
+
GROK_HOOK_EVENT: 'stop',
|
|
53
|
+
GROK_SESSION_ID: sessionId,
|
|
54
|
+
GROK_WORKSPACE_ROOT: cwd,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
assert.equal(payload.session_id, `grok:${sessionId}`);
|
|
59
|
+
assert.equal(payload.hook_event_name, 'stop');
|
|
60
|
+
assert.equal(
|
|
61
|
+
payload.transcript_path,
|
|
62
|
+
join(home, '.grok', 'sessions', encodeURIComponent(cwd), sessionId, 'chat_history.jsonl'),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
29
66
|
test('normalizeHookPayload prefixes grok: and derives chat_history path', () => {
|
|
30
67
|
const home = '/tmp/tl-home';
|
|
31
68
|
const cwd = '/Users/kite/Developer/dotagents';
|
package/src/hosts/index.mjs
CHANGED
|
@@ -32,6 +32,9 @@ const ADAPTERS = Object.freeze({
|
|
|
32
32
|
* Grok は camelCase wire、Cursor は hook_event_name が sessionStart 等。
|
|
33
33
|
*/
|
|
34
34
|
export function normalizeHookPayload(payload, options = {}) {
|
|
35
|
+
if (options.env?.GROK_HOOK_EVENT || options.env?.GROK_SESSION_ID) {
|
|
36
|
+
return normalizeGrokHookPayload(payload, { ...options, force: true });
|
|
37
|
+
}
|
|
35
38
|
if (isGrokEnvelope(payload)) return normalizeGrokHookPayload(payload, options);
|
|
36
39
|
if (isCursorEnvelope(payload)) return normalizeCursorHookPayload(payload, options);
|
|
37
40
|
return payload;
|
|
@@ -43,7 +43,7 @@ export function spawnPortableSync(command, args, options = {}) {
|
|
|
43
43
|
return spawnSync(process.execPath, [resolved, ...args], spawnOptions);
|
|
44
44
|
}
|
|
45
45
|
if (extension === '.ps1') {
|
|
46
|
-
return spawnSync('
|
|
46
|
+
return spawnSync('pwsh.exe', [
|
|
47
47
|
'-NoLogo',
|
|
48
48
|
'-NoProfile',
|
|
49
49
|
'-NonInteractive',
|
|
@@ -16,9 +16,9 @@ $acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false)
|
|
|
16
16
|
$inherit=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
|
|
17
17
|
$rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$inherit,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
|
|
18
18
|
$acl.AddAccessRule($rule)
|
|
19
|
-
|
|
19
|
+
Set-Acl -LiteralPath $target -AclObject $acl
|
|
20
20
|
`;
|
|
21
|
-
const result = spawnSync('
|
|
21
|
+
const result = spawnSync('pwsh.exe', [
|
|
22
22
|
'-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script,
|
|
23
23
|
], {
|
|
24
24
|
encoding: 'utf8',
|
|
@@ -39,7 +39,7 @@ export function verifyWindowsPrivateAcl(path, directory = false) {
|
|
|
39
39
|
$ErrorActionPreference='Stop'
|
|
40
40
|
$target=$env:THROUGHLINE_TEST_ACL_PATH; $isDir=$env:THROUGHLINE_TEST_ACL_DIRECTORY -eq '1'
|
|
41
41
|
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
42
|
-
$acl=
|
|
42
|
+
$acl=Get-Acl -LiteralPath $target
|
|
43
43
|
$owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
44
44
|
if($owner -ne $sid){exit 41}
|
|
45
45
|
$rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))
|
|
@@ -47,7 +47,7 @@ if($rules.Count -ne 1){exit 42}
|
|
|
47
47
|
$rule=$rules[0]
|
|
48
48
|
if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow' -or $rule.IsInherited -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
|
|
49
49
|
`;
|
|
50
|
-
const result = spawnSync('
|
|
50
|
+
const result = spawnSync('pwsh.exe', [
|
|
51
51
|
'-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script,
|
|
52
52
|
], {
|
|
53
53
|
encoding: 'utf8',
|
package/src/os/windows-acl.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export function verifyWindowsAcl(path, directory) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function runWindowsAclScript(path, directory, script) {
|
|
36
|
-
const result = childProcess.spawnSync('
|
|
36
|
+
const result = childProcess.spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
37
37
|
env: { ...process.env, THROUGHLINE_ACL_PATH: path, THROUGHLINE_ACL_DIRECTORY: directory ? '1' : '0' },
|
|
38
38
|
stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
|
|
39
39
|
});
|
|
@@ -42,7 +42,7 @@ function runWindowsAclScript(path, directory, script) {
|
|
|
42
42
|
|
|
43
43
|
const WINDOWS_ACL_VERIFY_SCRIPT = String.raw`
|
|
44
44
|
$p=$env:THROUGHLINE_ACL_PATH; $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
45
|
-
$acl=
|
|
45
|
+
$acl=Get-Acl -LiteralPath $p
|
|
46
46
|
$owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
47
47
|
if($owner -ne $sid){exit 41}; $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if($rules.Count -ne 1){exit 42}
|
|
48
48
|
$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}
|
|
@@ -53,5 +53,5 @@ $p=$env:THROUGHLINE_ACL_PATH; $sid=[System.Security.Principal.WindowsIdentity]::
|
|
|
53
53
|
$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)
|
|
54
54
|
$flags=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
|
|
55
55
|
$rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
|
|
56
|
-
$acl.SetOwner($sid); $acl.AddAccessRule($rule);
|
|
56
|
+
$acl.SetOwner($sid); $acl.AddAccessRule($rule); Set-Acl -LiteralPath $p -AclObject $acl
|
|
57
57
|
` + WINDOWS_ACL_VERIFY_SCRIPT;
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -152,7 +152,7 @@ export async function run() {
|
|
|
152
152
|
process.stdin.on('end', resolve);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
const payload = normalizeHookPayload(JSON.parse(raw));
|
|
155
|
+
const payload = normalizeHookPayload(JSON.parse(raw), { env: process.env });
|
|
156
156
|
const { session_id, cwd, prompt } = payload;
|
|
157
157
|
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
158
158
|
|
|
@@ -123,7 +123,7 @@ test('runtime error store: one Windows mutation spends ACL processes only on dis
|
|
|
123
123
|
|
|
124
124
|
assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
|
|
125
125
|
assert.equal(calls.length, 3, 'new directory, lock, and store each require one apply+verify process');
|
|
126
|
-
assert.ok(calls.every((call) => call.command === '
|
|
126
|
+
assert.ok(calls.every((call) => call.command === 'pwsh.exe'));
|
|
127
127
|
calls.length = 0;
|
|
128
128
|
|
|
129
129
|
assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
|
package/src/session-start.mjs
CHANGED
|
@@ -101,7 +101,7 @@ export async function run() {
|
|
|
101
101
|
process.stdin.on('end', resolve);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
const payload = normalizeHookPayload(JSON.parse(raw));
|
|
104
|
+
const payload = normalizeHookPayload(JSON.parse(raw), { env: process.env });
|
|
105
105
|
const { session_id, cwd, source, transcript_path } = payload;
|
|
106
106
|
|
|
107
107
|
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
package/src/test-env.mjs
CHANGED
|
@@ -15,8 +15,9 @@ process.env.NODE_NO_WARNINGS = '1';
|
|
|
15
15
|
// app-server fixtures. Run their .mjs bodies with this test runner's Node
|
|
16
16
|
// executable, preserving the command arguments the production code sends.
|
|
17
17
|
import childProcess from 'node:child_process';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
19
|
import { syncBuiltinESMExports } from 'node:module';
|
|
20
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
20
21
|
|
|
21
22
|
const { spawn: nativeSpawn, spawnSync: nativeSpawnSync } = childProcess;
|
|
22
23
|
|
|
@@ -25,6 +26,16 @@ function normalizeNodeFixture(command, args = []) {
|
|
|
25
26
|
return [command, args];
|
|
26
27
|
}
|
|
27
28
|
if (command.endsWith('.mjs')) return [process.execPath, [command, ...args]];
|
|
29
|
+
const extension = extname(command).toLowerCase();
|
|
30
|
+
if (extension === '.cmd' || extension === '.bat') {
|
|
31
|
+
const sibling = join(dirname(command), `${basename(command, extension)}.ps1`);
|
|
32
|
+
if (existsSync(sibling)) {
|
|
33
|
+
return ['pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-File', sibling, ...args]];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (extension === '.ps1') {
|
|
37
|
+
return ['pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-File', command, ...args]];
|
|
38
|
+
}
|
|
28
39
|
try {
|
|
29
40
|
const header = readFileSync(command, 'utf8').slice(0, 128);
|
|
30
41
|
if (/^#!.*\bnode(?:\.exe)?\b/.test(header)) return [process.execPath, [command, ...args]];
|
package/src/turn-processor.mjs
CHANGED
|
@@ -191,7 +191,7 @@ export async function run() {
|
|
|
191
191
|
process.stdin.on('end', resolve);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
const payload = normalizeHookPayload(JSON.parse(raw || '{}'));
|
|
194
|
+
const payload = normalizeHookPayload(JSON.parse(raw || '{}'), { env: process.env });
|
|
195
195
|
const { session_id, transcript_path, cwd, last_assistant_message } = payload;
|
|
196
196
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
197
197
|
|