throughline 0.10.7 → 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 +21 -1
- package/README.ja.md +5 -2
- package/README.md +20 -3
- package/bin/throughline.mjs +4 -3
- package/docs/04_public_release_plan.md +1 -1
- package/package.json +1 -1
- package/src/cli/handoff-context.mjs +75 -12
- package/src/cli/handoff-context.test.mjs +111 -3
- package/src/cli/latest-session.test.mjs +2 -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/docs-contract.test.mjs +2 -2
- 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/runtime-error-store.test.mjs +1 -1
- package/src/test-env.mjs +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ 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
|
+
|
|
23
|
+
## [0.10.8] — 2026-09-01
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `handoff-context`へ任意の`--supplement-file`を追加した。補足JSONは元sessionの
|
|
28
|
+
`project_path`と一致する場合だけ、長期記憶・RAGとして既存の9,500字枠へ合成する。
|
|
29
|
+
DBの所有権と既存の補足なし出力は変えない。
|
|
30
|
+
|
|
13
31
|
## [0.10.7] — 2026-09-01
|
|
14
32
|
|
|
15
33
|
### Added
|
|
@@ -1379,7 +1397,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1379
1397
|
|
|
1380
1398
|
---
|
|
1381
1399
|
|
|
1382
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.
|
|
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
|
|
1402
|
+
[0.10.8]: https://github.com/kitepon/Throughline/compare/v0.10.7...v0.10.8
|
|
1383
1403
|
[0.10.7]: https://github.com/kitepon/Throughline/compare/v0.10.6...v0.10.7
|
|
1384
1404
|
[0.10.6]: https://github.com/kitepon/Throughline/compare/v0.10.5...v0.10.6
|
|
1385
1405
|
[0.10.5]: https://github.com/kitepon/Throughline/compare/v0.10.4...v0.10.5
|
package/README.ja.md
CHANGED
|
@@ -390,7 +390,7 @@ v0.10.4以前には `self-update` が存在しない。該当版からの初回
|
|
|
390
390
|
| `throughline runtime-errors snapshot --json` | boundedなlocal aggregateを読み取る(network I/Oなし) |
|
|
391
391
|
| `throughline runtime-errors diagnostics --json` | collection/store状態をpathやraw errorなしで診断 |
|
|
392
392
|
| `throughline handoff-preview --session <id>` | Codex 向け `throughline_handoff` JSON projection を表示 |
|
|
393
|
-
| `throughline handoff-context --session <id> --json` | SessionStart
|
|
393
|
+
| `throughline handoff-context --session <id> --json [--supplement-file <path>]` | SessionStartと同じ引き継ぎ文脈を取得。任意のproject束縛済み補足を、長期記憶・知識として同じ9,500字枠へ合成できる |
|
|
394
394
|
| `throughline latest-session --project <absolute-path> --json` | 指定した1プロジェクトだけを対象に直近セッションIDを読み取る。既存DBをread-onlyで開き、記録がなければ`empty`を返す |
|
|
395
395
|
| `throughline grok-continue --session <id>` | handoff-context を初手 user 文にした対話 Grok 席を立てる。cwd は源の `project_path`。ready でなければ spawn しない。`--rules` なし。macOS Terminal のみ |
|
|
396
396
|
| `throughline codex-sidecar-diagnostics` | この project の `codex-sidecar` diagnostics status を確認 |
|
|
@@ -426,7 +426,10 @@ throughline handoff-context --session codex:<thread-id> --json
|
|
|
426
426
|
```
|
|
427
427
|
|
|
428
428
|
成功時の`throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
|
|
429
|
-
`context`はSessionStart
|
|
429
|
+
`context`はSessionStartと同じ予算付き継承文脈である。任意の`--supplement-file <path>`には
|
|
430
|
+
`throughline.handoff_supplement.v1`、源sessionと同じ`projectPath`、`title`と`content`からなる
|
|
431
|
+
`sections`を指定する。補足は会話記憶と同じ9,500字枠へ入り、別projectの補足は拒否する。
|
|
432
|
+
DB作成・migration・baton消費・session merge・
|
|
430
433
|
latest session推測・`sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
|
|
431
434
|
別harness portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
|
|
432
435
|
|
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.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,
|
|
@@ -855,7 +855,7 @@ once with `npm install --global throughline@latest`, then run
|
|
|
855
855
|
| `throughline runtime-errors reopen <fingerprint> --json` | Explicitly reopen a resolved aggregate without fabricating a new occurrence |
|
|
856
856
|
| `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
|
|
857
857
|
| `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
|
|
858
|
-
| `throughline handoff-context --session <id> --json` | Print the
|
|
858
|
+
| `throughline handoff-context --session <id> --json [--supplement-file <path>]` | Print the SessionStart inheritance context without moving memory rows; an optional project-bound supplement adds long-term memory and knowledge inside the same 9,500-character budget |
|
|
859
859
|
| `throughline latest-session --project <absolute-path> --json` | Read the latest session id strictly scoped to one project; opens the existing database read-only and returns `empty` when the project has no captured session |
|
|
860
860
|
| `throughline grok-continue --session <id>` | Spawn a person-facing Grok seat whose first user text is the handoff-context body. cwd is the source session `project_path`. Does not spawn without ready context. No `--rules`. macOS Terminal only |
|
|
861
861
|
| `throughline codex-capture --codex-thread-id <id>` | Capture active Codex rollout turns into a `codex:<thread_id>` DB session |
|
|
@@ -909,7 +909,24 @@ throughline handoff-context --session codex:<thread-id> --json
|
|
|
909
909
|
|
|
910
910
|
The successful `throughline.handoff_context.v1` object contains only `schema`,
|
|
911
911
|
`status`, `sessionId`, and `context`. The context is the same budgeted
|
|
912
|
-
inheritance text used by SessionStart.
|
|
912
|
+
inheritance text used by SessionStart. A launcher may append
|
|
913
|
+
`--supplement-file <path>` with a `throughline.handoff_supplement.v1` JSON object:
|
|
914
|
+
|
|
915
|
+
```json
|
|
916
|
+
{
|
|
917
|
+
"schema": "throughline.handoff_supplement.v1",
|
|
918
|
+
"projectPath": "/absolute/bot/project",
|
|
919
|
+
"sections": [
|
|
920
|
+
{ "title": "Long-term memory", "content": "..." },
|
|
921
|
+
{ "title": "Relevant knowledge", "content": "..." }
|
|
922
|
+
]
|
|
923
|
+
}
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
The supplement is included only when `projectPath` matches the source session
|
|
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
|
|
913
930
|
database, consume a baton, merge sessions, infer a latest session, change
|
|
914
931
|
`sessions.merged_into`, or reassign L1/L2/L3 rows. AIterm uses this boundary for
|
|
915
932
|
its optional cross-harness portable fork; the Observer feed is a separate
|
package/bin/throughline.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* throughline detail <時刻> # L2+L3 詳細取得 (Claude が Bash 経由で呼ぶ想定)
|
|
12
12
|
* throughline recall --l2|--l1 # 注入案内から辿る pull 用 read-only 記憶取得
|
|
13
13
|
* throughline handoff-preview # Codex-facing throughline_handoff JSON preview
|
|
14
|
-
* throughline handoff-context --session <id> --json # Read-only inheritance context JSON
|
|
14
|
+
* throughline handoff-context --session <id> --json [--supplement-file <path>] # Read-only inheritance context JSON
|
|
15
15
|
* throughline latest-session --project <path> --json # Latest session strictly scoped to one project
|
|
16
16
|
* throughline grok-continue --session <id> # Spawn a Grok seat whose first user text is handoff-context
|
|
17
17
|
* throughline auditor-context --json # Read-only bounded auditor context JSON
|
|
@@ -236,9 +236,10 @@ Usage:
|
|
|
236
236
|
throughline monitor Multi-session token monitor (use --all, --session <id>)
|
|
237
237
|
throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
|
|
238
238
|
throughline handoff-preview Print Codex-facing throughline_handoff JSON
|
|
239
|
-
throughline handoff-context --session <id> --json
|
|
239
|
+
throughline handoff-context --session <id> --json [--supplement-file <path>]
|
|
240
240
|
Print the exact inheritance context without
|
|
241
|
-
changing database ownership
|
|
241
|
+
changing database ownership; optionally include
|
|
242
|
+
project-bound long-term memory and knowledge
|
|
242
243
|
throughline latest-session --project <absolute-path> --json
|
|
243
244
|
Read the latest session id for exactly one project
|
|
244
245
|
throughline grok-continue --session <id>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 現行状態
|
|
8
8
|
|
|
9
|
-
- release candidateのpackage版は `0.10.
|
|
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,36 +1,96 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { sameProjectPath } from '../project-path.mjs';
|
|
7
|
+
import {
|
|
8
|
+
buildBudgetedResumeContext,
|
|
9
|
+
INJECTION_BUDGET_CHARS,
|
|
10
|
+
} from '../resume-context.mjs';
|
|
7
11
|
|
|
8
12
|
export const HANDOFF_CONTEXT_SCHEMA = 'throughline.handoff_context.v1';
|
|
13
|
+
export const HANDOFF_SUPPLEMENT_SCHEMA = 'throughline.handoff_supplement.v1';
|
|
9
14
|
|
|
10
15
|
export function parseArgs(argv = []) {
|
|
11
|
-
|
|
12
|
-
argv.length !== 3 ||
|
|
16
|
+
const baseArgsInvalid =
|
|
17
|
+
(argv.length !== 3 && argv.length !== 5) ||
|
|
13
18
|
argv[0] !== '--session' ||
|
|
14
19
|
typeof argv[1] !== 'string' ||
|
|
15
20
|
argv[1].length === 0 ||
|
|
16
|
-
argv[2] !== '--json'
|
|
17
|
-
|
|
21
|
+
argv[2] !== '--json';
|
|
22
|
+
const supplementArgsValid = argv.length === 3 || (
|
|
23
|
+
argv[3] === '--supplement-file' &&
|
|
24
|
+
typeof argv[4] === 'string' &&
|
|
25
|
+
argv[4].length > 0
|
|
26
|
+
);
|
|
27
|
+
if (baseArgsInvalid || !supplementArgsValid) {
|
|
18
28
|
throw new TypeError('usage error');
|
|
19
29
|
}
|
|
20
|
-
return {
|
|
30
|
+
return {
|
|
31
|
+
sessionId: argv[1],
|
|
32
|
+
supplementFile: argv.length === 5 ? argv[4] : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderHandoffSupplement(value, projectPath) {
|
|
37
|
+
if (
|
|
38
|
+
value?.schema !== HANDOFF_SUPPLEMENT_SCHEMA ||
|
|
39
|
+
!isAbsolute(value.projectPath) ||
|
|
40
|
+
!sameProjectPath(value.projectPath, projectPath) ||
|
|
41
|
+
!Array.isArray(value.sections) ||
|
|
42
|
+
value.sections.length === 0
|
|
43
|
+
) {
|
|
44
|
+
throw new TypeError('invalid handoff supplement');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sections = value.sections.map((section) => {
|
|
48
|
+
if (
|
|
49
|
+
typeof section?.title !== 'string' || section.title.length === 0 ||
|
|
50
|
+
typeof section?.content !== 'string' || section.content.length === 0
|
|
51
|
+
) {
|
|
52
|
+
throw new TypeError('invalid handoff supplement section');
|
|
53
|
+
}
|
|
54
|
+
return `### ${section.title}\n${section.content}`;
|
|
55
|
+
});
|
|
56
|
+
return ['## このBotの長期記憶と関連知識', ...sections].join('\n\n');
|
|
21
57
|
}
|
|
22
58
|
|
|
23
59
|
export function readHandoffContext(sessionId, {
|
|
24
60
|
dbPath = join(homedir(), '.throughline', 'throughline.db'),
|
|
61
|
+
supplementFile = null,
|
|
25
62
|
} = {}) {
|
|
26
63
|
if (!existsSync(dbPath)) return null;
|
|
27
64
|
|
|
28
65
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
29
66
|
try {
|
|
30
|
-
|
|
67
|
+
let supplementContext = null;
|
|
68
|
+
if (supplementFile) {
|
|
69
|
+
const row = db.prepare(
|
|
70
|
+
'SELECT project_path FROM sessions WHERE session_id = ?',
|
|
71
|
+
).get(sessionId);
|
|
72
|
+
if (!row?.project_path) return null;
|
|
73
|
+
supplementContext = renderHandoffSupplement(
|
|
74
|
+
JSON.parse(readFileSync(supplementFile, 'utf8')),
|
|
75
|
+
row.project_path,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const separator = supplementContext ? '\n\n' : '';
|
|
80
|
+
const context = buildBudgetedResumeContext(db, {
|
|
31
81
|
sessionId,
|
|
32
82
|
isInheritance: true,
|
|
83
|
+
maxChars: INJECTION_BUDGET_CHARS - (supplementContext?.length ?? 0) - separator.length,
|
|
33
84
|
})?.text ?? null;
|
|
85
|
+
if (!context && !supplementContext) return null;
|
|
86
|
+
|
|
87
|
+
const combined = supplementContext && context
|
|
88
|
+
? `${supplementContext}${separator}${context}`
|
|
89
|
+
: supplementContext ?? context;
|
|
90
|
+
if (combined.length > INJECTION_BUDGET_CHARS) {
|
|
91
|
+
throw new RangeError('handoff context exceeds injection budget');
|
|
92
|
+
}
|
|
93
|
+
return combined;
|
|
34
94
|
} finally {
|
|
35
95
|
db.close();
|
|
36
96
|
}
|
|
@@ -61,16 +121,19 @@ export function run(argv = [], {
|
|
|
61
121
|
readContext = readHandoffContext,
|
|
62
122
|
} = {}) {
|
|
63
123
|
let sessionId;
|
|
124
|
+
let supplementFile;
|
|
64
125
|
try {
|
|
65
|
-
({ sessionId } = parseArgs(argv));
|
|
126
|
+
({ sessionId, supplementFile } = parseArgs(argv));
|
|
66
127
|
} catch {
|
|
67
|
-
stderr.write(
|
|
128
|
+
stderr.write(
|
|
129
|
+
'Usage: throughline handoff-context --session <id> --json [--supplement-file <path>]\n',
|
|
130
|
+
);
|
|
68
131
|
return 2;
|
|
69
132
|
}
|
|
70
133
|
|
|
71
134
|
let context;
|
|
72
135
|
try {
|
|
73
|
-
context = readContext(sessionId);
|
|
136
|
+
context = readContext(sessionId, { supplementFile });
|
|
74
137
|
} catch {
|
|
75
138
|
stderr.write('Throughline handoff context could not be read.\n');
|
|
76
139
|
return 1;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { DatabaseSync } from 'node:sqlite';
|
|
9
9
|
|
|
10
|
-
import { buildBudgetedResumeContext } from '../resume-context.mjs';
|
|
11
|
-
import { readSessionProjectPath } from './handoff-context.mjs';
|
|
10
|
+
import { buildBudgetedResumeContext, INJECTION_BUDGET_CHARS } from '../resume-context.mjs';
|
|
11
|
+
import { parseArgs, readSessionProjectPath } from './handoff-context.mjs';
|
|
12
12
|
|
|
13
13
|
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
|
|
14
14
|
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
@@ -132,6 +132,114 @@ test('handoff-context emits the exact inheritance context without changing DB ow
|
|
|
132
132
|
}
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test('handoff-context adds a project-bound supplement inside the shared budget', () => {
|
|
136
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-'));
|
|
137
|
+
try {
|
|
138
|
+
const { db, dbPath } = createFixture(home);
|
|
139
|
+
const before = ownershipSnapshot(db);
|
|
140
|
+
db.close();
|
|
141
|
+
const supplementFile = join(home, 'supplement.json');
|
|
142
|
+
writeFileSync(supplementFile, JSON.stringify({
|
|
143
|
+
schema: 'throughline.handoff_supplement.v1',
|
|
144
|
+
projectPath: '/work/project',
|
|
145
|
+
sections: [
|
|
146
|
+
{ title: '長期記憶', content: 'オーナーとの約束を大切にしている' },
|
|
147
|
+
{ title: '関連知識', content: 'BellTeamではBotごとにprojectを分離する' },
|
|
148
|
+
],
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const result = runCli(home, [
|
|
152
|
+
'handoff-context', '--session', SESSION_ID, '--json',
|
|
153
|
+
'--supplement-file', supplementFile,
|
|
154
|
+
]);
|
|
155
|
+
assert.equal(result.status, 0, result.stderr);
|
|
156
|
+
const context = JSON.parse(result.stdout).context;
|
|
157
|
+
assert.match(context, /このBotの長期記憶と関連知識/);
|
|
158
|
+
assert.match(context, /オーナーとの約束を大切にしている/);
|
|
159
|
+
assert.match(context, /BellTeamではBotごとにprojectを分離する/);
|
|
160
|
+
assert.match(context, /所有権を変えずに記憶を渡して/);
|
|
161
|
+
assert.ok(context.indexOf('長期記憶') < context.indexOf('直前の対話'));
|
|
162
|
+
assert.ok(context.length <= INJECTION_BUDGET_CHARS);
|
|
163
|
+
|
|
164
|
+
const verify = new DatabaseSync(dbPath, { readOnly: true });
|
|
165
|
+
assert.deepEqual(ownershipSnapshot(verify), before);
|
|
166
|
+
verify.close();
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(home, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
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
|
+
|
|
206
|
+
test('handoff-context refuses a supplement from another bot project', () => {
|
|
207
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-scope-'));
|
|
208
|
+
try {
|
|
209
|
+
const { db } = createFixture(home);
|
|
210
|
+
db.close();
|
|
211
|
+
const supplementFile = join(home, 'supplement.json');
|
|
212
|
+
writeFileSync(supplementFile, JSON.stringify({
|
|
213
|
+
schema: 'throughline.handoff_supplement.v1',
|
|
214
|
+
projectPath: '/work/other-bot',
|
|
215
|
+
sections: [{ title: '長期記憶', content: 'B_PRIVATE_MEMORY' }],
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
const result = runCli(home, [
|
|
219
|
+
'handoff-context', '--session', SESSION_ID, '--json',
|
|
220
|
+
'--supplement-file', supplementFile,
|
|
221
|
+
]);
|
|
222
|
+
assert.equal(result.status, 1);
|
|
223
|
+
assert.doesNotMatch(result.stdout, /B_PRIVATE_MEMORY/);
|
|
224
|
+
} finally {
|
|
225
|
+
rmSync(home, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('handoff-context accepts only the documented supplement argument shape', () => {
|
|
230
|
+
assert.deepEqual(parseArgs(['--session', 's', '--json']), {
|
|
231
|
+
sessionId: 's',
|
|
232
|
+
supplementFile: null,
|
|
233
|
+
});
|
|
234
|
+
assert.deepEqual(parseArgs([
|
|
235
|
+
'--session', 's', '--json', '--supplement-file', '/tmp/memory.json',
|
|
236
|
+
]), {
|
|
237
|
+
sessionId: 's',
|
|
238
|
+
supplementFile: '/tmp/memory.json',
|
|
239
|
+
});
|
|
240
|
+
assert.throws(() => parseArgs(['--session', 's', '--supplement-file', '/tmp/memory.json', '--json']));
|
|
241
|
+
});
|
|
242
|
+
|
|
135
243
|
test('readSessionProjectPath returns the source session project', () => {
|
|
136
244
|
const home = mkdtempSync(join(tmpdir(), 'tl-session-project-'));
|
|
137
245
|
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
|
});
|
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);
|
|
@@ -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
|
});
|
|
@@ -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;
|
|
@@ -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/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]];
|