throughline 0.10.13 → 0.10.15
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 +22 -1
- package/README.ja.md +16 -6
- package/README.md +33 -8
- package/bin/throughline.mjs +6 -4
- package/docs/04_public_release_plan.md +5 -2
- package/docs/05_codex_first_roadmap.md +5 -0
- package/docs/08_codex_dual_support.md +5 -2
- package/docs/adr/0023-handoff-disclosure-policy.md +5 -3
- package/package.json +2 -2
- package/src/cli/handoff-context.mjs +96 -23
- package/src/cli/handoff-context.test.mjs +110 -1
- package/src/cli/help.test.mjs +2 -1
- package/src/codex-app-server.mjs +1 -1
- package/src/codex-host-primitive-audit.mjs +1 -1
- package/src/docs-contract.test.mjs +5 -2
- package/src/os/portable-spawn-sync.mjs +21 -10
- package/src/os/portable-spawn-sync.test.mjs +51 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,25 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.10.15] — 2026-09-06
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Windowsでnpmから導入したCodexをThroughlineの新規タスク引き継ぎと診断から起動できるようにした。
|
|
18
|
+
OS境界の起動処理を通し、PowerShell 7経由の標準入出力をUTF-8で受け渡す。
|
|
19
|
+
- Windowsの起動試験を通常のテストと製品CIへ含め、日本語と接続中の要求・応答を検証する。
|
|
20
|
+
- Windowsの起動条件、Codexフックの承認、既存ログの回収を文書化した。
|
|
21
|
+
自動refreshの古い案内を直し、公開版と開発中の版の参照先を分けた。
|
|
22
|
+
|
|
23
|
+
## [0.10.14] — 2026-09-02
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `handoff-context --project <path> --json`で、指定project内の会話本文を持つ最新sessionを
|
|
28
|
+
read-onlyで選び、本文がなければ`empty`を返せるようにした。
|
|
29
|
+
- `--disclosure silent`を補足ファイルなしで指定できるようにし、埋込製品が短期記憶だけを
|
|
30
|
+
基盤案内なしで受け取れるようにした。
|
|
31
|
+
|
|
13
32
|
## [0.10.13] — 2026-09-02
|
|
14
33
|
|
|
15
34
|
### Fixed
|
|
@@ -1430,7 +1449,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1430
1449
|
|
|
1431
1450
|
---
|
|
1432
1451
|
|
|
1433
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.
|
|
1452
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.15...HEAD
|
|
1453
|
+
[0.10.15]: https://github.com/kitepon/Throughline/compare/v0.10.14...v0.10.15
|
|
1454
|
+
[0.10.14]: https://github.com/kitepon/Throughline/compare/v0.10.13...v0.10.14
|
|
1434
1455
|
[0.10.13]: https://github.com/kitepon/Throughline/compare/v0.10.12...v0.10.13
|
|
1435
1456
|
[0.10.12]: https://github.com/kitepon/Throughline/compare/v0.10.11...v0.10.12
|
|
1436
1457
|
[0.10.11]: https://github.com/kitepon/Throughline/compare/v0.10.10...v0.10.11
|
package/README.ja.md
CHANGED
|
@@ -75,8 +75,9 @@ cwd は源セッションの `project_path` であり、呼び出し元 cwd は
|
|
|
75
75
|
macOS Terminal のみ。
|
|
76
76
|
|
|
77
77
|
通常のhandoff-contextは、引き継いだことを最初の応答で一度だけ案内し、後続応答では
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
繰り返させない。埋込製品は`handoff-context --disclosure silent`で案内を消せる。
|
|
79
|
+
project束縛済み補足の`handoffDisclosure: "silent"`も同じ扱いになる。旧版がassistant本文へ
|
|
80
|
+
付けた固定宣言は次回引き継ぎから除外し、ユーザーの
|
|
80
81
|
引用と実際の応答本文は保持する。
|
|
81
82
|
|
|
82
83
|
新席は `~/.grok/sessions/<encodeURIComponent(cwd)>/` のトップレベル
|
|
@@ -101,6 +102,11 @@ current-thread rollback
|
|
|
101
102
|
診断が要る時だけ明示的に `trim --execute --host codex` を使う。既存の
|
|
102
103
|
非 Throughline Codex hook は保持される。
|
|
103
104
|
|
|
105
|
+
登録後は `throughline doctor --codex` で承認状態も確認する。未承認のフックは
|
|
106
|
+
Codexのフック承認画面で利用者が承認する。Throughlineは承認状態を書き換えない。
|
|
107
|
+
Windowsの起動条件と、記録が見つからない場合の確認手順は
|
|
108
|
+
[Codexの診断と復旧](README.md#codex-windows)を参照。
|
|
109
|
+
|
|
104
110
|
</details>
|
|
105
111
|
|
|
106
112
|
## 他の手段との比較
|
|
@@ -395,7 +401,7 @@ v0.10.4以前には `self-update` が存在しない。該当版からの初回
|
|
|
395
401
|
| `throughline runtime-errors snapshot --json` | boundedなlocal aggregateを読み取る(network I/Oなし) |
|
|
396
402
|
| `throughline runtime-errors diagnostics --json` | collection/store状態をpathやraw errorなしで診断 |
|
|
397
403
|
| `throughline handoff-preview --session <id>` | Codex 向け `throughline_handoff` JSON projection を表示 |
|
|
398
|
-
| `throughline handoff-context --session <id>
|
|
404
|
+
| `throughline handoff-context (--session <id> \| --project <path>) --json` | SessionStartと同じ引き継ぎ文脈を取得。project指定時は会話本文を持つ最新sessionを選び、`--disclosure silent`に対応し、本文がなければ`empty`を返す。session指定時はproject束縛済み補足を同じ9,500字枠へ合成できる |
|
|
399
405
|
| `throughline latest-session --project <absolute-path> --json` | 指定した1プロジェクトだけを対象に直近セッションIDを読み取る。既存DBをread-onlyで開き、記録がなければ`empty`を返す |
|
|
400
406
|
| `throughline grok-continue --session <id>` | handoff-context を初手 user 文にした対話 Grok 席を立てる。cwd は源の `project_path`。ready でなければ spawn しない。`--rules` なし。macOS Terminal のみ |
|
|
401
407
|
| `throughline codex-sidecar-diagnostics` | この project の `codex-sidecar` diagnostics status を確認 |
|
|
@@ -428,15 +434,18 @@ throughline runtime-errors diagnostics --json
|
|
|
428
434
|
|
|
429
435
|
```bash
|
|
430
436
|
throughline handoff-context --session codex:<thread-id> --json
|
|
437
|
+
throughline handoff-context --project /absolute/bot/project --json --disclosure silent
|
|
431
438
|
```
|
|
432
439
|
|
|
433
|
-
|
|
434
|
-
|
|
440
|
+
`throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
|
|
441
|
+
`--project`ではそのproject内で会話本文を持つ最新sessionを選び、存在しなければ`empty`を返す。
|
|
442
|
+
`--disclosure silent`は長期記憶を追加せずThroughlineの案内を消す。`context`はSessionStartと
|
|
443
|
+
同じ予算付き継承文脈である。`--session`で使える任意の`--supplement-file <path>`には
|
|
435
444
|
`throughline.handoff_supplement.v1`、源sessionと同じ`projectPath`、`title`と`content`からなる
|
|
436
445
|
`sections`を指定する。任意の`handoffDisclosure`は`visible`(既定)または`silent`を受け取る。
|
|
437
446
|
補足は会話記憶と同じ9,500字枠へ入り、別projectの補足は拒否する。
|
|
438
447
|
DB作成・migration・baton消費・session merge・
|
|
439
|
-
|
|
448
|
+
`sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
|
|
440
449
|
別harness portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
|
|
441
450
|
|
|
442
451
|
スラッシュコマンド (Claude Code 内でユーザーが叩く):
|
|
@@ -460,6 +469,7 @@ latest session推測・`sessions.merged_into`変更・L1/L2/L3 rowの所属変
|
|
|
460
469
|
- **Codex CLI ログイン**(既定の L1 要約 backend、`gpt-5.6-luna`)または
|
|
461
470
|
**Claude Max サブスクリプション**(`claude -p` 経由の Haiku fallback)— どちらも API キー不要
|
|
462
471
|
- 対応 OS: **Windows / macOS / Linux**
|
|
472
|
+
- Windowsでnpm版Codexを起動する場合は **PowerShell 7**(`pwsh.exe`)がPATHに必要。
|
|
463
473
|
|
|
464
474
|
ランタイム依存 **ゼロ**。npm パッケージは純 `.mjs` ファイルのみで構成されています。
|
|
465
475
|
|
package/README.md
CHANGED
|
@@ -72,6 +72,8 @@ DB handoff memory as a developer item, and opens that thread in the selected
|
|
|
72
72
|
host; ask explicitly for current-thread rollback diagnostics when you want the
|
|
73
73
|
guarded `trim --execute --host codex` surface.
|
|
74
74
|
|
|
75
|
+
フック登録後の承認とWindowsでの起動条件は、[Codexの診断と復旧](#codex-windows)を参照。
|
|
76
|
+
|
|
75
77
|
</details>
|
|
76
78
|
|
|
77
79
|
<details>
|
|
@@ -817,7 +819,7 @@ entry to the `tasks` array yourself:
|
|
|
817
819
|
|
|
818
820
|
## Commands
|
|
819
821
|
|
|
820
|
-
|
|
822
|
+
公開版は冒頭のnpmバッジ、開発中の版は `package.json`、版ごとの変更は [CHANGELOG.md](CHANGELOG.md) を参照。Throughline supports Claude Code, Codex,
|
|
821
823
|
Grok, and Cursor as documented host adapters. The versioned, read-only
|
|
822
824
|
`handoff-context` boundary opens only an existing database and leaves baton
|
|
823
825
|
state, session ownership, and memory rows unchanged. Factory diagnostics,
|
|
@@ -826,8 +828,9 @@ under the same explicit-failure and local-only contracts.
|
|
|
826
828
|
|
|
827
829
|
Normal handoffs announce inherited continuity once in the first response and
|
|
828
830
|
explicitly forbid repeating that line in later responses. A project-bound
|
|
829
|
-
|
|
830
|
-
|
|
831
|
+
launcher may pass `--disclosure silent` for an embedded product that keeps
|
|
832
|
+
continuity metadata out of its chat. A supplement may also set
|
|
833
|
+
`handoffDisclosure` to `silent`. Legacy Throughline disclosure lines
|
|
831
834
|
in assistant memory are omitted from the next handoff; user quotations and the
|
|
832
835
|
actual reply body remain intact.
|
|
833
836
|
|
|
@@ -862,7 +865,7 @@ once with `npm install --global throughline@latest`, then run
|
|
|
862
865
|
| `throughline runtime-errors reopen <fingerprint> --json` | Explicitly reopen a resolved aggregate without fabricating a new occurrence |
|
|
863
866
|
| `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
|
|
864
867
|
| `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
|
|
865
|
-
| `throughline handoff-context --session <id>
|
|
868
|
+
| `throughline handoff-context (--session <id> \| --project <path>) --json` | Print the SessionStart inheritance context without moving memory rows. Project mode selects the newest captured session with dialogue, supports `--disclosure silent`, and returns `empty` when no dialogue exists. Session mode may add a project-bound supplement inside the same 9,500-character budget |
|
|
866
869
|
| `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 |
|
|
867
870
|
| `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 |
|
|
868
871
|
| `throughline codex-capture --codex-thread-id <id>` | Capture active Codex rollout turns into a `codex:<thread_id>` DB session |
|
|
@@ -912,11 +915,15 @@ performing a normal handoff:
|
|
|
912
915
|
|
|
913
916
|
```bash
|
|
914
917
|
throughline handoff-context --session codex:<thread-id> --json
|
|
918
|
+
throughline handoff-context --project /absolute/bot/project --json --disclosure silent
|
|
915
919
|
```
|
|
916
920
|
|
|
917
|
-
The
|
|
918
|
-
`
|
|
919
|
-
|
|
921
|
+
The `throughline.handoff_context.v1` object contains only `schema`, `status`,
|
|
922
|
+
`sessionId`, and `context`. With `--project`, Throughline selects the newest
|
|
923
|
+
session in that project that has dialogue context; it returns `status: "empty"`
|
|
924
|
+
when none exists. `--disclosure silent` removes the Throughline announcement
|
|
925
|
+
without adding long-term memory. The context uses the same budget as
|
|
926
|
+
SessionStart. With `--session`, a launcher may append
|
|
920
927
|
`--supplement-file <path>` with a `throughline.handoff_supplement.v1` JSON object:
|
|
921
928
|
|
|
922
929
|
```json
|
|
@@ -936,7 +943,7 @@ The supplement is included only when `projectPath` matches the source session
|
|
|
936
943
|
and shares the 9,500-character budget with conversation memory. If the captured
|
|
937
944
|
session has no dialogue context yet, a valid project-bound supplement is returned
|
|
938
945
|
by itself. The command does not create or migrate a
|
|
939
|
-
database, consume a baton, merge sessions,
|
|
946
|
+
database, consume a baton, merge sessions, change
|
|
940
947
|
`sessions.merged_into`, or reassign L1/L2/L3 rows. AIterm uses this boundary for
|
|
941
948
|
its optional cross-harness portable fork; the Observer feed is a separate
|
|
942
949
|
completed-turn projection and is not a substitute.
|
|
@@ -1113,6 +1120,24 @@ See [`src/haiku-summarizer.mjs`](src/haiku-summarizer.mjs) for the implementatio
|
|
|
1113
1120
|
|
|
1114
1121
|
## Troubleshooting
|
|
1115
1122
|
|
|
1123
|
+
<a id="codex-windows"></a>
|
|
1124
|
+
|
|
1125
|
+
### Codexの診断と復旧(Windowsを含む)
|
|
1126
|
+
|
|
1127
|
+
Windowsでnpm版Codexを使う場合、`codex` とPowerShell 7の `pwsh.exe` がPATHに必要。
|
|
1128
|
+
Throughlineはnpmが生成したPowerShell起動ファイルを使い、引数と標準入出力を
|
|
1129
|
+
UTF-8で渡す。macOS/Linuxの起動方法は変わらない。
|
|
1130
|
+
|
|
1131
|
+
引き継ぐ記録が見つからない場合は、対象プロジェクトで `throughline doctor --codex` を実行する。
|
|
1132
|
+
登録済みでも未承認のフックは動作しないため、Codexのフック承認画面で利用者が承認する。
|
|
1133
|
+
Throughlineのinstallや更新は、その承認を代行しない。
|
|
1134
|
+
|
|
1135
|
+
旧版を使っている場合は、[更新手順](docs/04_public_release_plan.md#製品単独運用)で更新する。
|
|
1136
|
+
未承認期間の既存ログを回収する必要がある場合は、対象IDを確定して
|
|
1137
|
+
`throughline codex-capture --codex-thread-id <id> --json` を実行する。
|
|
1138
|
+
その後、通常のターン終了で記録が増えることを確認する。一度の手動回収を
|
|
1139
|
+
自動記録の復旧とは扱わない。現在のタスクへのrollbackや注入は復旧手順に含めない。
|
|
1140
|
+
|
|
1116
1141
|
### Claude Code Desktop `/clear`
|
|
1117
1142
|
|
|
1118
1143
|
Automatic inheritance (the auto path) does not fire for `/clear` in Claude Code Desktop: the client does not send `source="clear"` and also mislabels the SessionEnd `reason` as `"other"`. This is reported upstream in [anthropics/claude-code#76704](https://github.com/anthropics/claude-code/issues/76704). On Desktop, run `/tl` before `/clear` (the baton lasts one hour). The 0.6.0 backfill work means this `/tl` handoff carries complete L2 through the immediately preceding turn. The VS Code extension's `/clear` auto-handoff works normally.
|
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>
|
|
14
|
+
* throughline handoff-context (--session <id> | --project <path>) --json # 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,10 +236,12 @@ 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>
|
|
239
|
+
throughline handoff-context (--session <id> | --project <path>) --json
|
|
240
240
|
Print the exact inheritance context without
|
|
241
|
-
changing database ownership
|
|
242
|
-
|
|
241
|
+
changing database ownership. Project mode selects
|
|
242
|
+
the newest session with dialogue and may use
|
|
243
|
+
--disclosure silent. Session mode may use a
|
|
244
|
+
project-bound --supplement-file
|
|
243
245
|
throughline latest-session --project <absolute-path> --json
|
|
244
246
|
Read the latest session id for exactly one project
|
|
245
247
|
throughline grok-continue --session <id>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 現行状態
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- 候補の版は `package.json`、公開版はnpm registryを正とする。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の判断は
|
|
@@ -58,7 +58,8 @@ Windows PowerShell 5.1へ切り替えない。更新前CLIを再利用せず、
|
|
|
58
58
|
|
|
59
59
|
release候補は次の全条件を満たしたときだけ公開する。
|
|
60
60
|
|
|
61
|
-
1. `package.json
|
|
61
|
+
1. `package.json` と `CHANGELOG.md` の候補版が一致し、READMEとCLAUDE.mdが版の正本を参照する。
|
|
62
|
+
schema・host契約も実装と一致し、`npm run verify:docs` が全Markdownと梱包内リンクを検査する。
|
|
62
63
|
2. 変更に直結するfocused testがgreenで、その後に `npm test` を最終確認として1回通す。
|
|
63
64
|
3. `npm run verify:release-commit` が、clean working treeかつHEADが`origin`既定ブランチの
|
|
64
65
|
祖先であることを確認する。既定ブランチへ着地していないcommitからpublishしない。
|
|
@@ -76,6 +77,8 @@ publish済みでも、上の確認が終わるまではrelease完了としない
|
|
|
76
77
|
- Claude Code: user/project hooksとslash command。PATH解決型CLIを使う。
|
|
77
78
|
- Codex: UserPromptSubmit / PostToolUse / Stop hook、feature flags、`$throughline` skill。
|
|
78
79
|
絶対Node + installed CLI pathを使う。
|
|
80
|
+
登録と利用者の承認は別であり、未承認は `doctor --codex` で確認する。
|
|
81
|
+
Windowsのnpm版Codex起動は `src/os/portable-spawn-sync.mjs` が所有し、PowerShell 7を使う。
|
|
79
82
|
- Grok: `~/.grok/hooks/throughline.json`と`grok-continue`。絶対pathを使う。
|
|
80
83
|
Grok予約環境変数でhostを確定し、camelCase/snake_caseの属性名を同じ境界で正規化する。
|
|
81
84
|
- Cursor: `~/.cursor/hooks.json`へsessionStart / beforeSubmitPrompt / stopをupsertし、
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
## 現状認識
|
|
24
24
|
|
|
25
|
+
Windowsの通常の新規タスク引き継ぎは実機確認済み。
|
|
26
|
+
起動条件とフック承認は [README](../README.md#codex-windows)、
|
|
27
|
+
当時の検証と未完了事項は [実測記録](https://github.com/kitepon/Throughline/blob/main/evidence/2026-09-06-windows-codex-handoff.md) を参照する。
|
|
28
|
+
この確認は同一タスクのrollbackや自動記録の承認を保証しない。
|
|
29
|
+
|
|
25
30
|
- Throughline 本体は、ほとんどが hook から起動されるローカル Node.js コードベースで動く。
|
|
26
31
|
- 現行 Claude path は、Claude hooks、slash command、handoff baton、DB、resume context、L1 / L2 / L3 persistence で成立している。
|
|
27
32
|
- 外部モデル的に呼ぶ主要箇所は L2 -> L1 要約。
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
|
|
22
22
|
Throughline は agent-neutral な handoff / context compression infrastructure になるべきです。
|
|
23
23
|
|
|
24
|
-
Claude Code transcript と handoff behavior
|
|
24
|
+
Claude Code transcript と handoff behavior を維持し、Codex対応はadapterとして追加する。
|
|
25
|
+
Codexの保存元は実装済みの `src/codex-capture.mjs` が読むrolloutであり、
|
|
26
|
+
`codex:<thread_id>` の記憶を保存する。通常の引き継ぎは新規タスクへdeveloper itemを注入する。
|
|
27
|
+
フック承認とWindows起動条件は [README](../README.md#codex-windows) を正とする。
|
|
25
28
|
|
|
26
29
|
目指す形:
|
|
27
30
|
|
|
@@ -33,7 +36,7 @@ Claude Code transcript と handoff behavior は守りつつ、Codex primary brid
|
|
|
33
36
|
## 優先順位
|
|
34
37
|
|
|
35
38
|
1. Throughline を Codex primary で使えるようにする。Codex primary の L2 -> L1 backend は Codex CLI を本線にする。
|
|
36
|
-
2. Codex
|
|
39
|
+
2. Codexの同一タスクのtrimは明示的な診断として維持する。自動refreshは無効であり、通常の `$throughline` は新規タスクへ引き継ぐ。同一タスクでの継続的な使用量削減は保証しない。
|
|
37
40
|
3. そのあと Claude 側の `/rewind` UX / 自動化 surface を詰める。
|
|
38
41
|
|
|
39
42
|
Claude transcript handling の置き換えから始めないでください。Codex 対応は adapter / bridge / Codex primary entrypoint として追加します。
|
|
@@ -20,8 +20,10 @@ Nターン分の記憶を引き継いだ」と応答冒頭で宣言させてい
|
|
|
20
20
|
|
|
21
21
|
- 通常の引き継ぎは、引き継ぎの事実と保持ターン数を最初の応答で一度だけ案内する。
|
|
22
22
|
- 同じhost sessionの2回目以降の応答では案内を繰り返させない。
|
|
23
|
-
-
|
|
24
|
-
|
|
23
|
+
- `handoff-context`は`--disclosure visible|silent`を受け取り、`silent`を指定した埋込製品では
|
|
24
|
+
補足ファイルを作らずに案内を消せる。
|
|
25
|
+
- project束縛済み`throughline.handoff_supplement.v1`も任意の`handoffDisclosure`として
|
|
26
|
+
`visible`(既定)または`silent`を持てる。
|
|
25
27
|
- Throughline自身が旧版でassistantへ付けた固定宣言行は、後継へ渡すassistant本文から除外する。
|
|
26
28
|
- ユーザー発言に含まれる同じ文字列は会話内容として保持する。
|
|
27
29
|
- 引き継ぎの成立はsession対応、生成コンテキスト、継続応答の内容で検証する。宣言の反復を
|
|
@@ -30,6 +32,6 @@ Nターン分の記憶を引き継いだ」と応答冒頭で宣言させてい
|
|
|
30
32
|
## Consequences
|
|
31
33
|
|
|
32
34
|
- 通常のThroughline利用では従来の引き継ぎ案内が残る。
|
|
33
|
-
-
|
|
35
|
+
- 埋込製品はproject分離境界と公開CLI引数を使って案内だけを非表示にできる。
|
|
34
36
|
- 旧宣言が保存済みのsessionも、次回の正規引き継ぎでメタ行を持ち越さない。
|
|
35
37
|
- 保存済みの原会話は監査記録として変更しない。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "throughline",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
|
|
6
6
|
"keywords": [
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"LICENSE"
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
|
-
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.test.mjs src/hosts/*.test.mjs",
|
|
44
|
+
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.test.mjs src/hosts/*.test.mjs src/os/*.test.mjs",
|
|
45
45
|
"verify:docs": "node scripts/verify-docs.mjs && node scripts/verify-pack-markdown-links.mjs",
|
|
46
46
|
"verify:release-commit": "node scripts/verify-release-commit.mjs",
|
|
47
47
|
"prepublishOnly": "npm run verify:release-commit && npm test"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { sameProjectPath } from '../project-path.mjs';
|
|
6
6
|
import { openReadOnlyDb } from '../db.mjs';
|
|
@@ -13,23 +13,50 @@ export const HANDOFF_CONTEXT_SCHEMA = 'throughline.handoff_context.v1';
|
|
|
13
13
|
export const HANDOFF_SUPPLEMENT_SCHEMA = 'throughline.handoff_supplement.v1';
|
|
14
14
|
|
|
15
15
|
export function parseArgs(argv = []) {
|
|
16
|
-
|
|
17
|
-
(argv
|
|
18
|
-
argv[0] !== '--session' ||
|
|
16
|
+
if (
|
|
17
|
+
(argv[0] !== '--session' && argv[0] !== '--project') ||
|
|
19
18
|
typeof argv[1] !== 'string' ||
|
|
20
19
|
argv[1].length === 0 ||
|
|
21
|
-
argv[2] !== '--json'
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
typeof argv[4] === 'string' &&
|
|
25
|
-
argv[4].length > 0
|
|
26
|
-
);
|
|
27
|
-
if (baseArgsInvalid || !supplementArgsValid) {
|
|
20
|
+
argv[2] !== '--json' ||
|
|
21
|
+
argv.length % 2 === 0
|
|
22
|
+
) {
|
|
28
23
|
throw new TypeError('usage error');
|
|
29
24
|
}
|
|
25
|
+
|
|
26
|
+
let supplementFile = null;
|
|
27
|
+
let handoffDisclosure = 'visible';
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (let index = 3; index < argv.length; index += 2) {
|
|
30
|
+
const option = argv[index];
|
|
31
|
+
const value = argv[index + 1];
|
|
32
|
+
if (
|
|
33
|
+
seen.has(option) ||
|
|
34
|
+
typeof value !== 'string' ||
|
|
35
|
+
value.length === 0
|
|
36
|
+
) {
|
|
37
|
+
throw new TypeError('usage error');
|
|
38
|
+
}
|
|
39
|
+
seen.add(option);
|
|
40
|
+
if (option === '--supplement-file') {
|
|
41
|
+
supplementFile = value;
|
|
42
|
+
} else if (option === '--disclosure' && ['visible', 'silent'].includes(value)) {
|
|
43
|
+
handoffDisclosure = value;
|
|
44
|
+
} else {
|
|
45
|
+
throw new TypeError('usage error');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
(argv[0] === '--project' && supplementFile) ||
|
|
50
|
+
(supplementFile && seen.has('--disclosure'))
|
|
51
|
+
) {
|
|
52
|
+
throw new TypeError('usage error');
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
return {
|
|
31
|
-
sessionId: argv[1],
|
|
32
|
-
|
|
56
|
+
sessionId: argv[0] === '--session' ? argv[1] : null,
|
|
57
|
+
projectPath: argv[0] === '--project' ? resolve(argv[1]) : null,
|
|
58
|
+
supplementFile,
|
|
59
|
+
handoffDisclosure,
|
|
33
60
|
};
|
|
34
61
|
}
|
|
35
62
|
|
|
@@ -61,13 +88,14 @@ export function renderHandoffSupplement(value, projectPath) {
|
|
|
61
88
|
export function readHandoffContext(sessionId, {
|
|
62
89
|
dbPath = join(homedir(), '.throughline', 'throughline.db'),
|
|
63
90
|
supplementFile = null,
|
|
91
|
+
handoffDisclosure = 'visible',
|
|
64
92
|
} = {}) {
|
|
65
93
|
if (!existsSync(dbPath)) return null;
|
|
66
94
|
|
|
67
95
|
const db = openReadOnlyDb(dbPath);
|
|
68
96
|
try {
|
|
69
97
|
let supplementContext = null;
|
|
70
|
-
let
|
|
98
|
+
let resolvedHandoffDisclosure = handoffDisclosure;
|
|
71
99
|
if (supplementFile) {
|
|
72
100
|
const row = db.prepare(
|
|
73
101
|
'SELECT project_path FROM sessions WHERE session_id = ?',
|
|
@@ -75,14 +103,14 @@ export function readHandoffContext(sessionId, {
|
|
|
75
103
|
if (!row?.project_path) return null;
|
|
76
104
|
const supplement = JSON.parse(readFileSync(supplementFile, 'utf8'));
|
|
77
105
|
supplementContext = renderHandoffSupplement(supplement, row.project_path);
|
|
78
|
-
|
|
106
|
+
resolvedHandoffDisclosure = supplement.handoffDisclosure ?? handoffDisclosure;
|
|
79
107
|
}
|
|
80
108
|
|
|
81
109
|
const separator = supplementContext ? '\n\n' : '';
|
|
82
110
|
const context = buildBudgetedResumeContext(db, {
|
|
83
111
|
sessionId,
|
|
84
112
|
isInheritance: true,
|
|
85
|
-
handoffDisclosure,
|
|
113
|
+
handoffDisclosure: resolvedHandoffDisclosure,
|
|
86
114
|
maxChars: INJECTION_BUDGET_CHARS - (supplementContext?.length ?? 0) - separator.length,
|
|
87
115
|
})?.text ?? null;
|
|
88
116
|
if (!context && !supplementContext) return null;
|
|
@@ -99,6 +127,34 @@ export function readHandoffContext(sessionId, {
|
|
|
99
127
|
}
|
|
100
128
|
}
|
|
101
129
|
|
|
130
|
+
export function readLatestProjectHandoffContext(projectPath, {
|
|
131
|
+
dbPath = join(homedir(), '.throughline', 'throughline.db'),
|
|
132
|
+
handoffDisclosure = 'visible',
|
|
133
|
+
} = {}) {
|
|
134
|
+
if (!existsSync(dbPath)) return null;
|
|
135
|
+
|
|
136
|
+
const db = openReadOnlyDb(dbPath);
|
|
137
|
+
try {
|
|
138
|
+
const sessions = db.prepare(
|
|
139
|
+
`SELECT session_id, project_path
|
|
140
|
+
FROM sessions
|
|
141
|
+
ORDER BY updated_at DESC`,
|
|
142
|
+
).all();
|
|
143
|
+
for (const session of sessions) {
|
|
144
|
+
if (!sameProjectPath(session.project_path, projectPath)) continue;
|
|
145
|
+
const context = buildBudgetedResumeContext(db, {
|
|
146
|
+
sessionId: session.session_id,
|
|
147
|
+
isInheritance: true,
|
|
148
|
+
handoffDisclosure,
|
|
149
|
+
})?.text ?? null;
|
|
150
|
+
if (context) return { sessionId: session.session_id, context };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
} finally {
|
|
154
|
+
db.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
102
158
|
export function readSessionProjectPath(sessionId, {
|
|
103
159
|
dbPath = join(homedir(), '.throughline', 'throughline.db'),
|
|
104
160
|
} = {}) {
|
|
@@ -122,26 +178,43 @@ export function run(argv = [], {
|
|
|
122
178
|
stdout = process.stdout,
|
|
123
179
|
stderr = process.stderr,
|
|
124
180
|
readContext = readHandoffContext,
|
|
181
|
+
readProjectContext = readLatestProjectHandoffContext,
|
|
125
182
|
} = {}) {
|
|
126
183
|
let sessionId;
|
|
184
|
+
let projectPath;
|
|
127
185
|
let supplementFile;
|
|
186
|
+
let handoffDisclosure;
|
|
128
187
|
try {
|
|
129
|
-
({ sessionId, supplementFile } = parseArgs(argv));
|
|
188
|
+
({ sessionId, projectPath, supplementFile, handoffDisclosure } = parseArgs(argv));
|
|
130
189
|
} catch {
|
|
131
190
|
stderr.write(
|
|
132
|
-
'Usage: throughline handoff-context --session <id> --json [--supplement-file <path>]\n',
|
|
191
|
+
'Usage: throughline handoff-context (--session <id> | --project <path>) --json [--disclosure visible|silent | --supplement-file <path>]\n',
|
|
133
192
|
);
|
|
134
193
|
return 2;
|
|
135
194
|
}
|
|
136
195
|
|
|
137
|
-
let
|
|
196
|
+
let result;
|
|
138
197
|
try {
|
|
139
|
-
|
|
198
|
+
result = projectPath
|
|
199
|
+
? readProjectContext(projectPath, { handoffDisclosure })
|
|
200
|
+
: {
|
|
201
|
+
sessionId,
|
|
202
|
+
context: readContext(sessionId, { supplementFile, handoffDisclosure }),
|
|
203
|
+
};
|
|
140
204
|
} catch {
|
|
141
205
|
stderr.write('Throughline handoff context could not be read.\n');
|
|
142
206
|
return 1;
|
|
143
207
|
}
|
|
144
|
-
if (!context) {
|
|
208
|
+
if (!result?.context && projectPath) {
|
|
209
|
+
stdout.write(`${JSON.stringify({
|
|
210
|
+
schema: HANDOFF_CONTEXT_SCHEMA,
|
|
211
|
+
status: 'empty',
|
|
212
|
+
sessionId: null,
|
|
213
|
+
context: '',
|
|
214
|
+
})}\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if (!result?.context) {
|
|
145
218
|
stderr.write('Throughline handoff context is not available for that session.\n');
|
|
146
219
|
return 1;
|
|
147
220
|
}
|
|
@@ -149,8 +222,8 @@ export function run(argv = [], {
|
|
|
149
222
|
stdout.write(`${JSON.stringify({
|
|
150
223
|
schema: HANDOFF_CONTEXT_SCHEMA,
|
|
151
224
|
status: 'ready',
|
|
152
|
-
sessionId,
|
|
153
|
-
context,
|
|
225
|
+
sessionId: result.sessionId,
|
|
226
|
+
context: result.context,
|
|
154
227
|
})}\n`);
|
|
155
228
|
return 0;
|
|
156
229
|
}
|
|
@@ -8,7 +8,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { DatabaseSync } from 'node:sqlite';
|
|
9
9
|
|
|
10
10
|
import { buildBudgetedResumeContext, INJECTION_BUDGET_CHARS } from '../resume-context.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
parseArgs,
|
|
13
|
+
readLatestProjectHandoffContext,
|
|
14
|
+
readSessionProjectPath,
|
|
15
|
+
} from './handoff-context.mjs';
|
|
12
16
|
|
|
13
17
|
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
|
|
14
18
|
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
@@ -201,6 +205,93 @@ test('handoff-context supplement can silence disclosure without changing normal
|
|
|
201
205
|
}
|
|
202
206
|
});
|
|
203
207
|
|
|
208
|
+
test('handoff-context can silence disclosure without a long-term-memory supplement', () => {
|
|
209
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-direct-silent-'));
|
|
210
|
+
try {
|
|
211
|
+
const { db } = createFixture(home);
|
|
212
|
+
db.close();
|
|
213
|
+
|
|
214
|
+
const result = runCli(home, [
|
|
215
|
+
'handoff-context', '--session', SESSION_ID, '--json',
|
|
216
|
+
'--disclosure', 'silent',
|
|
217
|
+
]);
|
|
218
|
+
assert.equal(result.status, 0, result.stderr);
|
|
219
|
+
const context = JSON.parse(result.stdout).context;
|
|
220
|
+
assert.doesNotMatch(context, /この引き継ぎ直後の最初の応答だけ/);
|
|
221
|
+
assert.doesNotMatch(context, /Throughline で前のセッションから .* ターン分/);
|
|
222
|
+
assert.match(context, /所有権を変えずに記憶を渡して/);
|
|
223
|
+
} finally {
|
|
224
|
+
rmSync(home, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('handoff-context project selector skips the newest empty session', () => {
|
|
229
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-project-'));
|
|
230
|
+
try {
|
|
231
|
+
const { db, dbPath } = createFixture(home);
|
|
232
|
+
db.prepare(
|
|
233
|
+
`INSERT INTO sessions
|
|
234
|
+
(session_id, project_path, status, created_at, updated_at, merged_into)
|
|
235
|
+
VALUES ('newest-empty', '/work/project', 'active', ?, ?, NULL)`,
|
|
236
|
+
).run(1_700_000_005_000, 1_700_000_006_000);
|
|
237
|
+
db.prepare(
|
|
238
|
+
`INSERT INTO sessions
|
|
239
|
+
(session_id, project_path, status, created_at, updated_at, merged_into)
|
|
240
|
+
VALUES ('other-newest', '/work/other', 'active', ?, ?, NULL)`,
|
|
241
|
+
).run(1_700_000_007_000, 1_700_000_008_000);
|
|
242
|
+
db.prepare(
|
|
243
|
+
`INSERT INTO bodies
|
|
244
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
245
|
+
VALUES ('other-newest', 'other-newest', 1, 'user', 'OTHER_PROJECT_PRIVATE', 4, ?)`,
|
|
246
|
+
).run(1_700_000_007_500);
|
|
247
|
+
db.close();
|
|
248
|
+
|
|
249
|
+
const selected = readLatestProjectHandoffContext('/work/project', {
|
|
250
|
+
dbPath,
|
|
251
|
+
handoffDisclosure: 'silent',
|
|
252
|
+
});
|
|
253
|
+
assert.equal(selected.sessionId, SESSION_ID);
|
|
254
|
+
assert.match(selected.context, /所有権を変えずに記憶を渡して/);
|
|
255
|
+
|
|
256
|
+
const result = runCli(home, [
|
|
257
|
+
'handoff-context', '--project', '/work/project', '--json',
|
|
258
|
+
'--disclosure', 'silent',
|
|
259
|
+
]);
|
|
260
|
+
assert.equal(result.status, 0, result.stderr);
|
|
261
|
+
const payload = JSON.parse(result.stdout);
|
|
262
|
+
assert.equal(payload.status, 'ready');
|
|
263
|
+
assert.equal(payload.sessionId, SESSION_ID);
|
|
264
|
+
assert.match(payload.context, /所有権を変えずに記憶を渡して/);
|
|
265
|
+
assert.doesNotMatch(payload.context, /OTHER_PROJECT_PRIVATE/);
|
|
266
|
+
assert.doesNotMatch(payload.context, /この引き継ぎ直後の最初の応答だけ/);
|
|
267
|
+
} finally {
|
|
268
|
+
rmSync(home, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('handoff-context project selector returns empty when the project has no dialogue', () => {
|
|
273
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-project-empty-'));
|
|
274
|
+
try {
|
|
275
|
+
const { db } = createFixture(home);
|
|
276
|
+
db.exec('DELETE FROM details; DELETE FROM bodies; DELETE FROM skeletons;');
|
|
277
|
+
db.close();
|
|
278
|
+
|
|
279
|
+
const result = runCli(home, [
|
|
280
|
+
'handoff-context', '--project', '/work/project', '--json',
|
|
281
|
+
'--disclosure', 'silent',
|
|
282
|
+
]);
|
|
283
|
+
assert.equal(result.status, 0, result.stderr);
|
|
284
|
+
assert.deepEqual(JSON.parse(result.stdout), {
|
|
285
|
+
schema: 'throughline.handoff_context.v1',
|
|
286
|
+
status: 'empty',
|
|
287
|
+
sessionId: null,
|
|
288
|
+
context: '',
|
|
289
|
+
});
|
|
290
|
+
} finally {
|
|
291
|
+
rmSync(home, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
204
295
|
test('handoff-context returns a project-bound supplement when the captured session has no dialogue yet', () => {
|
|
205
296
|
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-only-'));
|
|
206
297
|
try {
|
|
@@ -261,15 +352,33 @@ test('handoff-context refuses a supplement from another bot project', () => {
|
|
|
261
352
|
test('handoff-context accepts only the documented supplement argument shape', () => {
|
|
262
353
|
assert.deepEqual(parseArgs(['--session', 's', '--json']), {
|
|
263
354
|
sessionId: 's',
|
|
355
|
+
projectPath: null,
|
|
264
356
|
supplementFile: null,
|
|
357
|
+
handoffDisclosure: 'visible',
|
|
265
358
|
});
|
|
266
359
|
assert.deepEqual(parseArgs([
|
|
267
360
|
'--session', 's', '--json', '--supplement-file', '/tmp/memory.json',
|
|
268
361
|
]), {
|
|
269
362
|
sessionId: 's',
|
|
363
|
+
projectPath: null,
|
|
270
364
|
supplementFile: '/tmp/memory.json',
|
|
365
|
+
handoffDisclosure: 'visible',
|
|
366
|
+
});
|
|
367
|
+
assert.deepEqual(parseArgs([
|
|
368
|
+
'--project', '.', '--json', '--disclosure', 'silent',
|
|
369
|
+
]), {
|
|
370
|
+
sessionId: null,
|
|
371
|
+
projectPath: process.cwd(),
|
|
372
|
+
supplementFile: null,
|
|
373
|
+
handoffDisclosure: 'silent',
|
|
271
374
|
});
|
|
272
375
|
assert.throws(() => parseArgs(['--session', 's', '--supplement-file', '/tmp/memory.json', '--json']));
|
|
376
|
+
assert.throws(() => parseArgs(['--project', '.', '--json', '--supplement-file', '/tmp/memory.json']));
|
|
377
|
+
assert.throws(() => parseArgs(['--session', 's', '--json', '--disclosure', 'hidden']));
|
|
378
|
+
assert.throws(() => parseArgs([
|
|
379
|
+
'--session', 's', '--json', '--disclosure', 'silent',
|
|
380
|
+
'--supplement-file', '/tmp/memory.json',
|
|
381
|
+
]));
|
|
273
382
|
});
|
|
274
383
|
|
|
275
384
|
test('readSessionProjectPath returns the source session project', () => {
|
package/src/cli/help.test.mjs
CHANGED
|
@@ -51,7 +51,8 @@ test('CLI help exposes guided Codex handoff and guarded execute semantics', () =
|
|
|
51
51
|
assert.match(result.stdout, /Use --execute to/);
|
|
52
52
|
assert.match(result.stdout, /--open-host auto\|desktop\|vscode\|cli\|none/);
|
|
53
53
|
assert.match(result.stdout, /throughline trim --execute/);
|
|
54
|
-
assert.match(result.stdout, /throughline handoff-context --session <id> --json/);
|
|
54
|
+
assert.match(result.stdout, /throughline handoff-context \(--session <id> \| --project <path>\) --json/);
|
|
55
|
+
assert.match(result.stdout, /--disclosure silent/);
|
|
55
56
|
assert.match(result.stdout, /throughline latest-session --project <absolute-path> --json/);
|
|
56
57
|
assert.match(result.stdout, /without\s+changing database ownership/);
|
|
57
58
|
assert.match(result.stdout, /injectable DB memory/);
|
package/src/codex-app-server.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from '
|
|
1
|
+
import { spawnPortableSync as spawnSync } from './os/portable-spawn-sync.mjs';
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -48,7 +48,7 @@ test('Markdown-only CI runs the product-owned documentation contract', () => {
|
|
|
48
48
|
assert.match(result.stdout, /packed Markdown links:/);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test('
|
|
51
|
+
test('現行文書は版の正本を参照し、変更履歴は候補版と一致する', () => {
|
|
52
52
|
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
53
53
|
const version = packageJson.version;
|
|
54
54
|
const currentSources = [
|
|
@@ -56,7 +56,10 @@ test('release candidate version is synchronized across current product documents
|
|
|
56
56
|
readFileSync(new URL('../CLAUDE.md', import.meta.url), 'utf8'),
|
|
57
57
|
readFileSync(new URL('../docs/04_public_release_plan.md', import.meta.url), 'utf8'),
|
|
58
58
|
];
|
|
59
|
-
for (const source of currentSources)
|
|
59
|
+
for (const source of currentSources) {
|
|
60
|
+
assert.match(source, /`package\.json`/);
|
|
61
|
+
assert.match(source, /公開版はnpm|公開版は冒頭のnpm/);
|
|
62
|
+
}
|
|
60
63
|
|
|
61
64
|
const changelog = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
|
|
62
65
|
assert.match(changelog, new RegExp(`^## \\[${version.replaceAll('.', '\\.')}\\]`, 'm'));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { basename, delimiter, dirname, extname, isAbsolute, join } from 'node:path';
|
|
4
4
|
|
|
@@ -28,31 +28,42 @@ function resolveWindowsCommand(command, env) {
|
|
|
28
28
|
return command;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
function portableInvocation(command, args, options = {}) {
|
|
32
32
|
const platform = options.platform ?? process.platform;
|
|
33
33
|
const spawnOptions = { ...options };
|
|
34
34
|
delete spawnOptions.platform;
|
|
35
35
|
spawnOptions.shell = false;
|
|
36
36
|
|
|
37
|
-
if (platform !== 'win32') return
|
|
37
|
+
if (platform !== 'win32') return [command, args, spawnOptions];
|
|
38
38
|
|
|
39
39
|
const env = spawnOptions.env ?? process.env;
|
|
40
40
|
const resolved = resolveWindowsCommand(command, env);
|
|
41
41
|
const extension = extname(resolved).toLowerCase();
|
|
42
42
|
if (['.js', '.cjs', '.mjs'].includes(extension)) {
|
|
43
|
-
return
|
|
43
|
+
return [process.execPath, [resolved, ...args], spawnOptions];
|
|
44
44
|
}
|
|
45
45
|
if (extension === '.ps1') {
|
|
46
|
-
|
|
46
|
+
const invocation = [resolved, ...args]
|
|
47
|
+
.map(value => `'${value.replaceAll("'", "''")}'`).join(' ');
|
|
48
|
+
return ['pwsh.exe', [
|
|
47
49
|
'-NoLogo',
|
|
48
50
|
'-NoProfile',
|
|
49
51
|
'-NonInteractive',
|
|
50
52
|
'-ExecutionPolicy',
|
|
51
53
|
'Bypass',
|
|
52
|
-
'-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
'-Command',
|
|
55
|
+
'[Console]::InputEncoding = [Console]::OutputEncoding = $OutputEncoding = ' +
|
|
56
|
+
'[System.Text.UTF8Encoding]::new($false); ' +
|
|
57
|
+
`& ${invocation}; exit $LASTEXITCODE`,
|
|
58
|
+
], spawnOptions];
|
|
56
59
|
}
|
|
57
|
-
return
|
|
60
|
+
return [resolved, args, spawnOptions];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function spawnPortableSync(command, args, options = {}) {
|
|
64
|
+
return spawnSync(...portableInvocation(command, args, options));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function spawnPortable(command, args, options = {}) {
|
|
68
|
+
return spawn(...portableInvocation(command, args, options));
|
|
58
69
|
}
|
|
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
6
|
+
import { spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { once } from 'node:events';
|
|
7
9
|
|
|
8
10
|
test('spawnPortableSync: Windows cmd shim preserves argv boundaries and stdin', {
|
|
9
11
|
skip: process.platform !== 'win32' ? 'Windows cmd shim contract' : undefined,
|
|
@@ -20,6 +22,7 @@ process.stdin.on('data', (chunk) => { input += chunk; });
|
|
|
20
22
|
process.stdin.on('end', () => {
|
|
21
23
|
process.stdout.write(JSON.stringify({ args: process.argv.slice(2), input }));
|
|
22
24
|
});
|
|
25
|
+
|
|
23
26
|
`);
|
|
24
27
|
writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(child)} %*\r\n`);
|
|
25
28
|
writeFileSync(powerShellShim, `& ${JSON.stringify(process.execPath)} ${JSON.stringify(child)} @args\nexit $LASTEXITCODE\n`);
|
|
@@ -38,3 +41,50 @@ process.stdin.on('end', () => {
|
|
|
38
41
|
rmSync(dir, { recursive: true, force: true });
|
|
39
42
|
}
|
|
40
43
|
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
test('WindowsのPATHにあるnpm起動ファイルと標準入出力で往復できる', {
|
|
47
|
+
skip: process.platform !== 'win32',
|
|
48
|
+
timeout: 10_000,
|
|
49
|
+
}, async () => {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-npm-stream-'));
|
|
51
|
+
let child;
|
|
52
|
+
try {
|
|
53
|
+
const script = join(dir, 'rpc.mjs');
|
|
54
|
+
writeFileSync(script, `
|
|
55
|
+
import { createInterface } from 'node:readline';
|
|
56
|
+
createInterface({ input: process.stdin }).on('line', (line) => {
|
|
57
|
+
process.stdout.write(JSON.stringify({ line, args: process.argv.slice(2) }) + '\\n');
|
|
58
|
+
});
|
|
59
|
+
`);
|
|
60
|
+
// npmのPowerShell起動ファイルと同じ入力分岐で、EOF前の応答を確かめる。
|
|
61
|
+
writeFileSync(join(dir, 'tl-rpc.ps1'), `
|
|
62
|
+
if ($MyInvocation.ExpectingInput) {
|
|
63
|
+
$input | & ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args
|
|
64
|
+
} else {
|
|
65
|
+
& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args
|
|
66
|
+
}
|
|
67
|
+
exit $LASTEXITCODE
|
|
68
|
+
`);
|
|
69
|
+
const env = { ...process.env };
|
|
70
|
+
const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path');
|
|
71
|
+
env[pathKey] = `${dir};${env[pathKey]}`;
|
|
72
|
+
const args = ['空白 を含む', 'a&b', '100%'];
|
|
73
|
+
const sync = spawnPortableSync('tl-rpc', args, { env, encoding: 'utf8', input: '同期\n' });
|
|
74
|
+
assert.equal(sync.status, 0, sync.stderr || sync.error?.message);
|
|
75
|
+
assert.deepEqual(JSON.parse(sync.stdout), { line: '同期', args });
|
|
76
|
+
child = spawnPortable('tl-rpc', args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
77
|
+
const lines = createInterface({ input: child.stdout });
|
|
78
|
+
for (const line of ['初期化', '次の要求']) {
|
|
79
|
+
const response = once(lines, 'line');
|
|
80
|
+
child.stdin.write(`${line}\n`);
|
|
81
|
+
assert.deepEqual(JSON.parse((await response)[0]), { line, args });
|
|
82
|
+
}
|
|
83
|
+
const closed = once(child, 'close');
|
|
84
|
+
child.stdin.end();
|
|
85
|
+
assert.equal((await closed)[0], 0);
|
|
86
|
+
} finally {
|
|
87
|
+
child?.kill();
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|