throughline 0.10.8 → 0.10.9

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 CHANGED
@@ -10,6 +10,16 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.9] — 2026-09-01
14
+
15
+ ### Fixed
16
+
17
+ - `handoff-context --supplement-file`は、capture済みsessionに会話本文がまだ無い場合でも、
18
+ 同じprojectに束縛された補足記憶だけを9,500字予算内で返す。Cursorの初回Workspace
19
+ Trust直後など、session行だけが先に作られたBotも記憶付きで再起動できる。
20
+ - Windowsの子プロセスとowner-only ACL処理を、工場標準のPowerShell 7へ統一した。
21
+ 製品CIも現行の3環境(macOS・Linux workstation・Windows native)を直接検証する。
22
+
13
23
  ## [0.10.8] — 2026-09-01
14
24
 
15
25
  ### Added
@@ -1387,7 +1397,8 @@ two attempts, instrument first instead of patching again.
1387
1397
 
1388
1398
  ---
1389
1399
 
1390
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.8...HEAD
1400
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.9...HEAD
1401
+ [0.10.9]: https://github.com/kitepon/Throughline/compare/v0.10.8...v0.10.9
1391
1402
  [0.10.8]: https://github.com/kitepon/Throughline/compare/v0.10.7...v0.10.8
1392
1403
  [0.10.7]: https://github.com/kitepon/Throughline/compare/v0.10.6...v0.10.7
1393
1404
  [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.8.** Throughline supports Claude Code, Codex,
820
+ **The current release is v0.10.9.** 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. The command does not create or migrate a
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.8`。tagとnpmの公開確認はpublish後に行う。
9
+ - release candidateのpackage版は `0.10.9`。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の判断は
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -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
  }
@@ -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,7 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { join, resolve } from 'node:path';
5
5
  import { DatabaseSync } from 'node:sqlite';
6
6
  import test from 'node:test';
7
7
 
@@ -49,7 +49,7 @@ test('readyとemptyをJSONで返す', () => {
49
49
  assert.deepEqual(JSON.parse(stdout.read()), {
50
50
  schema: 'throughline.latest_session.v1',
51
51
  status: 'ready',
52
- projectPath: '/bots/a',
52
+ projectPath: resolve('/bots/a'),
53
53
  sessionId: 'claude:abc',
54
54
  updatedAt: 'now',
55
55
  });
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { realpathSync } from 'node:fs';
3
- import { dirname, isAbsolute, join, win32 } from 'node:path';
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 : { isAbsolute, join };
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 : { isAbsolute };
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 === 'powershell.exe'));
151
+ assert.ok(calls.every((call) => call.command === 'pwsh.exe'));
152
152
  calls.length = 0;
153
153
 
154
154
  writeCompletedTurnReceipt(input(2), options);
@@ -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 = spawnSync(npmCommand, ['run', 'verify:docs', '--silent'], {
39
+ const result = spawnPortableSync(npmCommand, ['run', 'verify:docs', '--silent'], {
40
40
  cwd: fileURLToPath(new URL('..', import.meta.url)),
41
41
  encoding: 'utf8',
42
42
  });
@@ -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('powershell.exe', [
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
- if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO.File]::SetAccessControl($target,$acl)}
19
+ Set-Acl -LiteralPath $target -AclObject $acl
20
20
  `;
21
- const result = spawnSync('powershell.exe', [
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=if($isDir){[System.IO.Directory]::GetAccessControl($target)}else{[System.IO.File]::GetAccessControl($target)}
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('powershell.exe', [
50
+ const result = spawnSync('pwsh.exe', [
51
51
  '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script,
52
52
  ], {
53
53
  encoding: 'utf8',
@@ -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('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
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=if($isDir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)}
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); if($isDir){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}
56
+ $acl.SetOwner($sid); $acl.AddAccessRule($rule); Set-Acl -LiteralPath $p -AclObject $acl
57
57
  ` + WINDOWS_ACL_VERIFY_SCRIPT;
@@ -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 === 'powershell.exe'));
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/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]];