throughline 0.8.8 → 0.9.0
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 +31 -1
- package/README.ja.md +4 -1
- package/README.md +4 -1
- package/bin/throughline.mjs +9 -0
- package/docs/00_overview.md +1 -0
- package/docs/04_public_release_plan.md +2 -1
- package/docs/16_readonly_handoff_context_plan.md +31 -0
- package/package.json +4 -2
- package/src/cli/doctor.mjs +8 -3
- package/src/cli/factory-diagnostics.mjs +3 -2
- package/src/cli/factory-diagnostics.test.mjs +43 -1
- package/src/cli/handoff-context.mjs +71 -0
- package/src/cli/handoff-context.test.mjs +143 -0
- package/src/cli/help.test.mjs +7 -0
- package/src/cli/install.mjs +56 -0
- package/src/cli/install.test.mjs +61 -0
- package/src/windows-acl-test-helper.mjs +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,35 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.9.0] — 2026-08-04
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `throughline handoff-context --session <id> --json` returns the exact
|
|
18
|
+
budgeted SessionStart inheritance context through a versioned local CLI
|
|
19
|
+
boundary. It opens only an existing database read-only and never creates or
|
|
20
|
+
migrates it, consumes a baton, merges sessions, changes `sessions.merged_into`,
|
|
21
|
+
or reassigns L1/L2/L3 memory rows.
|
|
22
|
+
|
|
23
|
+
## [0.8.9] — 2026-08-02
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Codex hook diagnosis no longer depends on the caller's `PATH`. The expected
|
|
28
|
+
hook command is rebuilt per invocation, and `resolveCodexHookNodePath` returns
|
|
29
|
+
the PATH form of Node when one is on `PATH` and `process.execPath` otherwise.
|
|
30
|
+
Comparing that string against the registered command classified a correctly
|
|
31
|
+
installed hook as a legacy command whenever the two representations differed,
|
|
32
|
+
so `doctor --codex` reported "legacy command needs reinstall" and
|
|
33
|
+
`factory-diagnostics` reported `codex_hooks` as `not_ready` for every scheduled
|
|
34
|
+
run started from a minimal environment. Hook commands are now compared by
|
|
35
|
+
parsed identity — same Node executable by realpath, same CLI script by
|
|
36
|
+
realpath, same event — so a hook registered as `/opt/homebrew/bin/node` still
|
|
37
|
+
matches an expectation resolved to `/opt/homebrew/Cellar/node/<ver>/bin/node`.
|
|
38
|
+
Hooks pointing at a different Throughline installation, a different event, or
|
|
39
|
+
the legacy PATH-resolved form remain flagged for reinstall, and paths whose
|
|
40
|
+
realpath cannot be resolved are never treated as equivalent.
|
|
41
|
+
|
|
13
42
|
## [0.8.8] — 2026-08-02
|
|
14
43
|
|
|
15
44
|
### Fixed
|
|
@@ -1191,7 +1220,8 @@ two attempts, instrument first instead of patching again.
|
|
|
1191
1220
|
|
|
1192
1221
|
---
|
|
1193
1222
|
|
|
1194
|
-
[Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.
|
|
1223
|
+
[Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.9...HEAD
|
|
1224
|
+
[0.8.9]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.8...v0.8.9
|
|
1195
1225
|
[0.8.8]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.7...v0.8.8
|
|
1196
1226
|
[0.8.7]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.6...v0.8.7
|
|
1197
1227
|
[0.8.6]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.5...v0.8.6
|
package/README.ja.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src=".github/og.png" alt="Throughline —
|
|
2
|
+
<img src=".github/og.png" alt="Throughline — 環境や境界が変わっても方向と記憶を保って進むクジラの群れ" width="100%">
|
|
3
|
+
<br>
|
|
4
|
+
<sub><em>この画像は、環境や境界が変わっても、関係・方向・記憶を失わずに進み続ける連続性を表しています。</em></sub>
|
|
3
5
|
</p>
|
|
4
6
|
|
|
5
7
|
# Throughline
|
|
@@ -332,6 +334,7 @@ Throughline state をまだ書いていない現在セッションも表示で
|
|
|
332
334
|
| `throughline doctor` | Node バージョン、hook 登録状況、DB、PATH をチェック |
|
|
333
335
|
| `throughline doctor --trim --host claude` | trim boundary と手動手順を診断 |
|
|
334
336
|
| `throughline handoff-preview --session <id>` | Codex 向け `throughline_handoff` JSON projection を表示 |
|
|
337
|
+
| `throughline handoff-context --session <id> --json` | SessionStart と同じ引き継ぎ文脈を versioned JSON で取得。記憶行の `session_id` と `sessions.merged_into` は変更せず、同一端末内の別ベンダーランチャーから使える |
|
|
335
338
|
| `throughline codex-sidecar-diagnostics` | この project の `codex-sidecar` diagnostics status を確認 |
|
|
336
339
|
| `throughline codex-sidecar-dry-run` | App Server を呼ばずに read-only sidecar request を正規化表示 |
|
|
337
340
|
| `throughline trim --dry-run --host codex` | Codex same-thread trim の dry-run preview |
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src=".github/og.png" alt="Throughline —
|
|
2
|
+
<img src=".github/og.png" alt="Throughline — a whale family carrying direction and memory across changing boundaries" width="100%">
|
|
3
|
+
<br>
|
|
4
|
+
<sub><em>This image represents continuity that preserves relationships, direction, and memory even as environments and boundaries change.</em></sub>
|
|
3
5
|
</p>
|
|
4
6
|
|
|
5
7
|
# Throughline
|
|
@@ -797,6 +799,7 @@ aggregate は collection が既定OFFで、canonical dotagents config の
|
|
|
797
799
|
| `throughline runtime-errors reopen <fingerprint> --json` | Explicitly reopen a resolved aggregate without fabricating a new occurrence |
|
|
798
800
|
| `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
|
|
799
801
|
| `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
|
|
802
|
+
| `throughline handoff-context --session <id> --json` | Print the exact SessionStart inheritance context as versioned JSON without moving memory rows or changing `sessions.merged_into`; intended for local launchers that need portable cross-vendor context |
|
|
800
803
|
| `throughline codex-capture --codex-thread-id <id>` | Capture active Codex rollout turns into a `codex:<thread_id>` DB session |
|
|
801
804
|
| `throughline codex-summarize --session codex:<id>` | Summarize captured Codex L2 into L1 with the Codex CLI backend |
|
|
802
805
|
| `throughline codex-resume --session codex:<id>` | Render Codex active-work context from a captured Codex session |
|
package/bin/throughline.mjs
CHANGED
|
@@ -11,6 +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
15
|
* throughline auditor-context --json # Read-only bounded auditor context JSON
|
|
15
16
|
* throughline factory-diagnostics --json # Native factory read-only readiness JSON
|
|
16
17
|
* throughline migrate --json # Migrate the existing Throughline database only
|
|
@@ -76,6 +77,11 @@ switch (cmd) {
|
|
|
76
77
|
case 'handoff-preview':
|
|
77
78
|
await (await import('../src/cli/handoff-preview.mjs')).run(rest);
|
|
78
79
|
break;
|
|
80
|
+
case 'handoff-context': {
|
|
81
|
+
const exitCode = (await import('../src/cli/handoff-context.mjs')).run(rest);
|
|
82
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
79
85
|
case 'auditor-context': {
|
|
80
86
|
const exitCode = (await import('../src/cli/auditor-context.mjs')).run(rest);
|
|
81
87
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
@@ -202,6 +208,9 @@ Usage:
|
|
|
202
208
|
throughline monitor Multi-session token monitor (use --all, --session <id>)
|
|
203
209
|
throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
|
|
204
210
|
throughline handoff-preview Print Codex-facing throughline_handoff JSON
|
|
211
|
+
throughline handoff-context --session <id> --json
|
|
212
|
+
Print the exact inheritance context without
|
|
213
|
+
changing database ownership
|
|
205
214
|
throughline auditor-context --session <id> --project <root>
|
|
206
215
|
Read only bounded completed user/assistant context
|
|
207
216
|
for an auditor; requires either --host plus --transcript,
|
package/docs/00_overview.md
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
| [13_native_factory_diagnostics_plan.md](13_native_factory_diagnostics_plan.md) | native factory read-only readiness 診断の実装記録 |
|
|
21
21
|
| [14_observer_completed_turn_feed_plan.md](14_observer_completed_turn_feed_plan.md) | Observer向けcompleted-only read / wait CLIのactive計画。CLI・opaque cursor・pagination・最大3600秒waitは実装済み、公開/full regression gateは継続中 |
|
|
22
22
|
| [15_windows_ci_release_latency_plan.md](15_windows_ci_release_latency_plan.md) | Windows CI 18分の原因、ACL安全網を維持した短縮、release gateの受入条件 |
|
|
23
|
+
| [16_readonly_handoff_context_plan.md](16_readonly_handoff_context_plan.md) | DB所有権を変更せずSessionStartと同じ記憶を返す、ローカルランチャー向けread-only I/F |
|
|
23
24
|
| [BUGHUB_RUNTIME_ERROR_STORE_PLAN.md](BUGHUB_RUNTIME_ERROR_STORE_PLAN.md) | local runtime error aggregate store の契約と実装 TODO |
|
|
24
25
|
|
|
25
26
|
## Supporting Records
|
|
@@ -143,7 +143,8 @@ schema v4 で PostToolUse (`capture-tool`) は廃止、L2/L3 は Stop 内で一
|
|
|
143
143
|
| **npm 公開 (v0.8.4): Codex handoff host境界の明示化** | `$throughline` skillは現在のCodex UI surfaceからDesktop/VS Code/CLIを選び、`--open-host`を明示する。shell/永続PTYの継承環境はsurface判定に使わない。`codex-handoff-start`は互換の`openHost`を残し、requested / resolved hostをJSONとtextで報告する。古いVS Code由来PTY事故形と配布skillをfocused testで固定し、Claude-facing hook/`/tl`/baton/resume契約は変更しない。公開commit `5b840b6`、CI run `29721583754` 9/9 green、npm `latest` 0.8.4、shasum `1f2c39a22e45f3e02e8739ee5fd6ceefc6a71034`、tag / GitHub Release、registry由来global install、配置skill一致、`doctor --codex` exit 0を2026-07-20に確認した。判断証拠は [ADR 0017](adr/0017-codex-handoff-host-boundary.md) |
|
|
144
144
|
| **npm公開 (v0.8.6): 製品所有DB migration入口** | `throughline migrate --json` は既存DBだけをproduction migrationで現行schemaへ移行し、versioned bounded JSONを返す。DB不在は作らず`not_applicable`、現行は`already_current`、future schemaと失敗は非0。`factory-diagnostics`のread-only契約は維持する。0.8.5はdry-run後に別作業の未コミット文書が混入したためdeprecateし、同一runtimeをclean worktreeから0.8.6として再公開した。設計判断は [ADR 0018](adr/0018-product-owned-database-migration.md) |
|
|
145
145
|
| **公開済み (v0.8.7): Windows CI release latency** | completed-turn receiptの256件境界fixtureと実ACL検証を分離し、production mutationのPowerShellはdistinct state transitionだけに限定する。temporary ACL失敗時の旧store保持とdirectory/lock/final storeのnative ACL testを維持する。CIは9 matrixのままevent/ref単位concurrency、unit test 5分SLO/8分timeoutとする。設計判断は [ADR 0020](adr/0020-windows-ci-release-latency.md) |
|
|
146
|
-
| **
|
|
146
|
+
| **npm公開 (v0.8.8): Codex hook timeout key修理** | Codex `hooks.json` の3 managed hookは秒単位の正規key `timeout`を30/30/300で書く。無視される旧`timeoutSec` entryは次回global installでcommand identityにより置換し、doctorは旧形式を要再install、factory diagnosticsはnot readyとする。Claude hooksとObserver wait deadline APIは変更しない。npm `latest` として2026-08-02T00:43Zに公開済み。tag / GitHub Releaseは未作成(要作成) |
|
|
147
|
+
| **npm公開 (v0.8.9): Codex hook診断のPATH非依存化** | 期待hook commandのnode表記は呼び出し元の`PATH`で変わるため、登録済みcommandとの文字列比較では最小PATH(launchd factory reporter等)から正規登録が「legacy command」と誤判定され、`codex_hooks`が恒常的に`not_ready`になっていた。比較を解析済みidentity(node実体realpath+CLI script realpath+event)へ変更した。別install/別event/旧PATH解決型/realpath未解決は従来どおり要再install扱い。hooks.jsonの書式とClaude-facing hookは変更しない。公開commit `5dbd3af`、CI run `30731697079` 9/9 green、npm `latest` 0.8.9、shasum `780f8e72d9bd4b0c4a331db48116a415a4bde34b`、tag / GitHub Release(v0.8.8分も遡って作成)、registry由来global installを2026-08-02に確認した |
|
|
147
148
|
| **未リリース: Codex current-session 75% trigger** | Codex 自動発火を token-monitor に依存させず、global install が Codex `UserPromptSubmit` / `PostToolUse` hooks も登録する。hook は当該 Codex session の rollout `token_count` を直接読み、verified 75% 以上なら同じ user turn または tool loop 継続前に `$throughline` workflow 実行指示を `additionalContext` で注入する。Stop hook の guarded auto-refresh は残す。`~/.codex/config.toml` は旧 `codex_hooks = true` に加えて現行 `hooks = true` も有効化する |
|
|
148
149
|
| **グローバル E2E 検証** | 2026-04-17 別ディレクトリから `throughline doctor` 全緑を確認 |
|
|
149
150
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Read-only handoff context
|
|
2
|
+
|
|
3
|
+
## 目的
|
|
4
|
+
|
|
5
|
+
通常 handoff が使う `buildBudgetedResumeContext` の出力を、DB 所有権を変えずにローカルプログラムが
|
|
6
|
+
取得できる read-only CLI として公開する。最初の consumer は AIterm の portable fork だが、I/F は
|
|
7
|
+
特定 consumer や vendor に依存させない。
|
|
8
|
+
|
|
9
|
+
実行 ToDo、依存、状態、完了証拠の正本は Lattice plan `readonly-handoff-context` とする。
|
|
10
|
+
|
|
11
|
+
## 契約
|
|
12
|
+
|
|
13
|
+
- `throughline handoff-context --session <id> --json` は session を明示必須とする。
|
|
14
|
+
- 既存 DB を `DatabaseSync(..., { readOnly: true })` で開き、作成・migration・書込みをしない。
|
|
15
|
+
- `buildBudgetedResumeContext(db, { sessionId, isInheritance: true })` をそのまま使う。
|
|
16
|
+
- 成功 JSON は schema、status、sessionId、context だけを返す。renderer 統計は公開しない。
|
|
17
|
+
- 引数不正、DB open 失敗、context 不在は非 0 終了とする。
|
|
18
|
+
|
|
19
|
+
## 非目標
|
|
20
|
+
|
|
21
|
+
- baton、pending handoff、merge、backfill、通常 SessionStart / UserPromptSubmit の変更。
|
|
22
|
+
- Observer projection、Codex 専用 renderer、latest session 推測、project/cwd 照合。
|
|
23
|
+
- DB schema 全面診断、hash、cursor、暗号化、daemon、network I/O、retry、cache。
|
|
24
|
+
- budget や `excludeOriginId` を CLI option として公開すること。
|
|
25
|
+
|
|
26
|
+
## 受入条件
|
|
27
|
+
|
|
28
|
+
- CLI の context が同じ DB に対する既存 budgeted renderer の出力と完全一致する。
|
|
29
|
+
- 実行前後で `sessions.merged_into` と L1/L2/L3 の `session_id` が変わらない。
|
|
30
|
+
- DB 不在時に DB や親ディレクトリを作らない。
|
|
31
|
+
- focused test と全回帰が成功し、公開 package と global install から新 command を実行できる。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "throughline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
|
|
6
6
|
"keywords": [
|
|
@@ -38,7 +38,9 @@
|
|
|
38
38
|
"LICENSE"
|
|
39
39
|
],
|
|
40
40
|
"scripts": {
|
|
41
|
-
"test": "node --import ./src/test-env.mjs --test src/*.test.mjs src/cli/*.test.mjs"
|
|
41
|
+
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.test.mjs",
|
|
42
|
+
"verify:release-commit": "node scripts/verify-release-commit.mjs",
|
|
43
|
+
"prepublishOnly": "npm run verify:release-commit && npm test"
|
|
42
44
|
},
|
|
43
45
|
"engines": {
|
|
44
46
|
"node": ">=22.13"
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
buildCodexPostToolUseHookCommand,
|
|
33
33
|
buildCodexStopHookCommand,
|
|
34
34
|
buildCodexUserPromptSubmitHookCommand,
|
|
35
|
+
isEquivalentCodexHookCommand,
|
|
35
36
|
isThroughlineCodexHookCommand,
|
|
36
37
|
isThroughlineCodexPostToolUseCommand,
|
|
37
38
|
isThroughlineCodexStopCommand,
|
|
@@ -576,13 +577,17 @@ function readCodexHookDiagnosis(codexHome) {
|
|
|
576
577
|
);
|
|
577
578
|
const stopHooks = listHooksWithTrust(parsed, 'Stop', 'stop', hooksPath, out.trustedStateKeys);
|
|
578
579
|
out.managedPromptHooks = promptHooks.filter(h => isThroughlineCodexHookCommand(h.command));
|
|
579
|
-
out.legacyManagedPromptHooks = out.managedPromptHooks.filter(
|
|
580
|
+
out.legacyManagedPromptHooks = out.managedPromptHooks.filter(
|
|
581
|
+
h => !isEquivalentCodexHookCommand(h.command, expectedPromptCommand),
|
|
582
|
+
);
|
|
580
583
|
out.managedPostToolUseHooks = postToolUseHooks.filter(h => isThroughlineCodexPostToolUseCommand(h.command));
|
|
581
584
|
out.legacyManagedPostToolUseHooks = out.managedPostToolUseHooks.filter(
|
|
582
|
-
h => h.command
|
|
585
|
+
h => !isEquivalentCodexHookCommand(h.command, expectedPostToolUseCommand),
|
|
583
586
|
);
|
|
584
587
|
out.managedStopHooks = stopHooks.filter(h => isThroughlineCodexStopCommand(h.command));
|
|
585
|
-
out.legacyManagedStopHooks = out.managedStopHooks.filter(
|
|
588
|
+
out.legacyManagedStopHooks = out.managedStopHooks.filter(
|
|
589
|
+
h => !isEquivalentCodexHookCommand(h.command, expectedStopCommand),
|
|
590
|
+
);
|
|
586
591
|
out.managedHookTrust = summarizeHookTrust(
|
|
587
592
|
[
|
|
588
593
|
...out.managedPromptHooks,
|
|
@@ -7,6 +7,7 @@ import { buildFactoryDiagnostics } from '../factory-diagnostics.mjs';
|
|
|
7
7
|
import { findCodexThreadCandidate, defaultCodexHome } from '../codex-thread-index.mjs';
|
|
8
8
|
import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
|
|
9
9
|
import { CURRENT_VERSION } from '../db.mjs';
|
|
10
|
+
import { isEquivalentCodexHookCommand } from './install.mjs';
|
|
10
11
|
import { _internal as doctorInternal } from './doctor.mjs';
|
|
11
12
|
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
@@ -126,8 +127,8 @@ function eventStatus({ hooks, legacyHooks, featureEnabled, expectedCommand, time
|
|
|
126
127
|
if (hooks.length === 0) return featureEnabled ? 'not_ready' : 'not_applicable';
|
|
127
128
|
if (!featureEnabled || legacyHooks.length > 0 || hooks.length !== 1) return 'not_ready';
|
|
128
129
|
const hook = hooks[0];
|
|
129
|
-
return hook.type === 'command' && hook.command
|
|
130
|
-
hook.async === false ? 'ready' : 'not_ready';
|
|
130
|
+
return hook.type === 'command' && isEquivalentCodexHookCommand(hook.command, expectedCommand) &&
|
|
131
|
+
hook.timeout === timeout && hook.async === false ? 'ready' : 'not_ready';
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
export function inspectFactoryHooks({ codexHome = defaultCodexHome(), readHooks = doctorInternal.readCodexHookDiagnosis } = {}) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
-
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
@@ -202,6 +202,48 @@ test('factory-diagnostics hook inspection summarizes every canonical ready event
|
|
|
202
202
|
assert.equal(result.reason, 'hooks_inspected');
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
test('factory-diagnostics hook inspection: 最小 PATH 由来の node 別表記を not_ready にしない', () => {
|
|
206
|
+
// launchd の factory reporter は PATH に /opt/homebrew/bin を持たないため、期待値の
|
|
207
|
+
// node が PATH symlink ではなく実体表記になる。同一 node を指す限り ready を維持する。
|
|
208
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-factory-hook-'));
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(join(dir, 'bin'));
|
|
211
|
+
mkdirSync(join(dir, 'cellar'));
|
|
212
|
+
const realNode = join(dir, 'cellar', 'node');
|
|
213
|
+
const pathNode = join(dir, 'bin', 'node');
|
|
214
|
+
const script = join(dir, 'throughline.mjs');
|
|
215
|
+
writeFileSync(realNode, '');
|
|
216
|
+
writeFileSync(script, '');
|
|
217
|
+
symlinkSync(realNode, pathNode);
|
|
218
|
+
const registered = (event) => `${pathNode} ${script} codex-hook ${event}`;
|
|
219
|
+
const expected = (event) => `${realNode} ${script} codex-hook ${event}`;
|
|
220
|
+
|
|
221
|
+
const result = inspectFactoryHooks({
|
|
222
|
+
readHooks: () => ({
|
|
223
|
+
configExists: true,
|
|
224
|
+
configReadable: true,
|
|
225
|
+
hooksExists: true,
|
|
226
|
+
hooksReadable: true,
|
|
227
|
+
featureEnabled: true,
|
|
228
|
+
expectedPromptCommand: expected('user-prompt-submit'),
|
|
229
|
+
expectedPostToolUseCommand: expected('post-tool-use'),
|
|
230
|
+
expectedStopCommand: expected('stop'),
|
|
231
|
+
managedPromptHooks: [{ type: 'command', command: registered('user-prompt-submit'), timeout: 30, async: false }],
|
|
232
|
+
legacyManagedPromptHooks: [],
|
|
233
|
+
managedPostToolUseHooks: [{ type: 'command', command: registered('post-tool-use'), timeout: 30, async: false }],
|
|
234
|
+
legacyManagedPostToolUseHooks: [],
|
|
235
|
+
managedStopHooks: [{ type: 'command', command: registered('stop'), timeout: 300, async: false }],
|
|
236
|
+
legacyManagedStopHooks: [],
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
assert.deepEqual(result.events, { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' });
|
|
241
|
+
assert.equal(result.status, 'ready');
|
|
242
|
+
} finally {
|
|
243
|
+
rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
205
247
|
test('factory-diagnostics hook inspection rejects legacy timeoutSec keys', () => {
|
|
206
248
|
const result = inspectFactoryHooks({
|
|
207
249
|
readHooks: () => ({
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { buildBudgetedResumeContext } from '../resume-context.mjs';
|
|
7
|
+
|
|
8
|
+
export const HANDOFF_CONTEXT_SCHEMA = 'throughline.handoff_context.v1';
|
|
9
|
+
|
|
10
|
+
export function parseArgs(argv = []) {
|
|
11
|
+
if (
|
|
12
|
+
argv.length !== 3 ||
|
|
13
|
+
argv[0] !== '--session' ||
|
|
14
|
+
typeof argv[1] !== 'string' ||
|
|
15
|
+
argv[1].length === 0 ||
|
|
16
|
+
argv[2] !== '--json'
|
|
17
|
+
) {
|
|
18
|
+
throw new TypeError('usage error');
|
|
19
|
+
}
|
|
20
|
+
return { sessionId: argv[1] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readHandoffContext(sessionId, {
|
|
24
|
+
dbPath = join(homedir(), '.throughline', 'throughline.db'),
|
|
25
|
+
} = {}) {
|
|
26
|
+
if (!existsSync(dbPath)) return null;
|
|
27
|
+
|
|
28
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
29
|
+
try {
|
|
30
|
+
return buildBudgetedResumeContext(db, {
|
|
31
|
+
sessionId,
|
|
32
|
+
isInheritance: true,
|
|
33
|
+
})?.text ?? null;
|
|
34
|
+
} finally {
|
|
35
|
+
db.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function run(argv = [], {
|
|
40
|
+
stdout = process.stdout,
|
|
41
|
+
stderr = process.stderr,
|
|
42
|
+
readContext = readHandoffContext,
|
|
43
|
+
} = {}) {
|
|
44
|
+
let sessionId;
|
|
45
|
+
try {
|
|
46
|
+
({ sessionId } = parseArgs(argv));
|
|
47
|
+
} catch {
|
|
48
|
+
stderr.write('Usage: throughline handoff-context --session <id> --json\n');
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let context;
|
|
53
|
+
try {
|
|
54
|
+
context = readContext(sessionId);
|
|
55
|
+
} catch {
|
|
56
|
+
stderr.write('Throughline handoff context could not be read.\n');
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
if (!context) {
|
|
60
|
+
stderr.write('Throughline handoff context is not available for that session.\n');
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stdout.write(`${JSON.stringify({
|
|
65
|
+
schema: HANDOFF_CONTEXT_SCHEMA,
|
|
66
|
+
status: 'ready',
|
|
67
|
+
sessionId,
|
|
68
|
+
context,
|
|
69
|
+
})}\n`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
|
|
10
|
+
import { buildBudgetedResumeContext } from '../resume-context.mjs';
|
|
11
|
+
|
|
12
|
+
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
|
|
13
|
+
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
14
|
+
const SESSION_ID = 'claude-source-session';
|
|
15
|
+
|
|
16
|
+
function runCli(home, args = ['handoff-context', '--session', SESSION_ID, '--json']) {
|
|
17
|
+
return spawnSync(process.execPath, [BIN_PATH, ...args], {
|
|
18
|
+
cwd: REPO_ROOT,
|
|
19
|
+
env: { ...process.env, HOME: home, USERPROFILE: home },
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createFixture(home) {
|
|
25
|
+
const dir = join(home, '.throughline');
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
const dbPath = join(dir, 'throughline.db');
|
|
28
|
+
const db = new DatabaseSync(dbPath);
|
|
29
|
+
db.exec(`
|
|
30
|
+
PRAGMA user_version = 9;
|
|
31
|
+
CREATE TABLE sessions (
|
|
32
|
+
session_id TEXT PRIMARY KEY,
|
|
33
|
+
project_path TEXT NOT NULL,
|
|
34
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
35
|
+
created_at INTEGER NOT NULL,
|
|
36
|
+
updated_at INTEGER NOT NULL,
|
|
37
|
+
merged_into TEXT
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE skeletons (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
session_id TEXT NOT NULL,
|
|
42
|
+
turn_number INTEGER NOT NULL,
|
|
43
|
+
role TEXT NOT NULL,
|
|
44
|
+
summary TEXT NOT NULL,
|
|
45
|
+
created_at INTEGER NOT NULL,
|
|
46
|
+
origin_session_id TEXT
|
|
47
|
+
);
|
|
48
|
+
CREATE TABLE bodies (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
session_id TEXT NOT NULL,
|
|
51
|
+
origin_session_id TEXT NOT NULL,
|
|
52
|
+
turn_number INTEGER NOT NULL,
|
|
53
|
+
role TEXT NOT NULL,
|
|
54
|
+
text TEXT NOT NULL,
|
|
55
|
+
token_count INTEGER,
|
|
56
|
+
created_at INTEGER NOT NULL
|
|
57
|
+
);
|
|
58
|
+
CREATE TABLE details (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
session_id TEXT NOT NULL,
|
|
61
|
+
turn_number INTEGER,
|
|
62
|
+
tool_name TEXT NOT NULL,
|
|
63
|
+
input_text TEXT,
|
|
64
|
+
output_text TEXT,
|
|
65
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
created_at INTEGER NOT NULL,
|
|
67
|
+
origin_session_id TEXT,
|
|
68
|
+
kind TEXT NOT NULL DEFAULT 'tool_input',
|
|
69
|
+
source_id TEXT
|
|
70
|
+
);
|
|
71
|
+
`);
|
|
72
|
+
db.prepare(
|
|
73
|
+
`INSERT INTO sessions
|
|
74
|
+
(session_id, project_path, status, created_at, updated_at, merged_into)
|
|
75
|
+
VALUES (?, ?, 'active', ?, ?, NULL)`,
|
|
76
|
+
).run(SESSION_ID, '/work/project', 1_700_000_000_000, 1_700_000_004_000);
|
|
77
|
+
db.prepare(
|
|
78
|
+
`INSERT INTO skeletons
|
|
79
|
+
(session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
80
|
+
VALUES (?, ?, 1, 'assistant', ?, ?)`,
|
|
81
|
+
).run(SESSION_ID, 'older-origin', '以前に portable fork の方針を決めた', 1_700_000_001_000);
|
|
82
|
+
const insertBody = db.prepare(
|
|
83
|
+
`INSERT INTO bodies
|
|
84
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
85
|
+
VALUES (?, ?, 2, ?, ?, 8, ?)`,
|
|
86
|
+
);
|
|
87
|
+
insertBody.run(SESSION_ID, SESSION_ID, 'user', '所有権を変えずに記憶を渡して', 1_700_000_002_000);
|
|
88
|
+
insertBody.run(SESSION_ID, SESSION_ID, 'assistant', 'read-only I/F を実装する', 1_700_000_003_000);
|
|
89
|
+
db.prepare(
|
|
90
|
+
`INSERT INTO details
|
|
91
|
+
(session_id, origin_session_id, turn_number, tool_name, output_text, created_at, kind, source_id)
|
|
92
|
+
VALUES (?, ?, 2, 'Read', 'schema inspected', ?, 'tool_output', 'detail-1')`,
|
|
93
|
+
).run(SESSION_ID, SESSION_ID, 1_700_000_003_500);
|
|
94
|
+
return { db, dbPath };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ownershipSnapshot(db) {
|
|
98
|
+
return {
|
|
99
|
+
sessions: db.prepare('SELECT session_id, merged_into FROM sessions ORDER BY session_id').all(),
|
|
100
|
+
skeletons: db.prepare('SELECT id, session_id FROM skeletons ORDER BY id').all(),
|
|
101
|
+
bodies: db.prepare('SELECT id, session_id FROM bodies ORDER BY id').all(),
|
|
102
|
+
details: db.prepare('SELECT id, session_id FROM details ORDER BY id').all(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('handoff-context emits the exact inheritance context without changing DB ownership', () => {
|
|
107
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-context-'));
|
|
108
|
+
try {
|
|
109
|
+
const { db, dbPath } = createFixture(home);
|
|
110
|
+
const before = ownershipSnapshot(db);
|
|
111
|
+
const expected = buildBudgetedResumeContext(db, {
|
|
112
|
+
sessionId: SESSION_ID,
|
|
113
|
+
isInheritance: true,
|
|
114
|
+
})?.text;
|
|
115
|
+
db.close();
|
|
116
|
+
|
|
117
|
+
const result = runCli(home);
|
|
118
|
+
assert.equal(result.status, 0, result.stderr);
|
|
119
|
+
assert.deepEqual(JSON.parse(result.stdout), {
|
|
120
|
+
schema: 'throughline.handoff_context.v1',
|
|
121
|
+
status: 'ready',
|
|
122
|
+
sessionId: SESSION_ID,
|
|
123
|
+
context: expected,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const verify = new DatabaseSync(dbPath, { readOnly: true });
|
|
127
|
+
assert.deepEqual(ownershipSnapshot(verify), before);
|
|
128
|
+
verify.close();
|
|
129
|
+
} finally {
|
|
130
|
+
rmSync(home, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('handoff-context fails without creating a missing database', () => {
|
|
135
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-handoff-context-missing-'));
|
|
136
|
+
try {
|
|
137
|
+
const result = runCli(home);
|
|
138
|
+
assert.notEqual(result.status, 0);
|
|
139
|
+
assert.equal(existsSync(join(home, '.throughline')), false);
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(home, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
package/src/cli/help.test.mjs
CHANGED
|
@@ -51,6 +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/);
|
|
55
|
+
assert.match(result.stdout, /without\s+changing database ownership/);
|
|
54
56
|
assert.match(result.stdout, /injectable DB memory/);
|
|
55
57
|
assert.match(result.stdout, /matching/);
|
|
56
58
|
assert.match(result.stdout, /rollout\/app-server turns/);
|
|
@@ -66,3 +68,8 @@ test('CLI help Codex commands are dispatchable', () => {
|
|
|
66
68
|
assert.match(bin, new RegExp(`case '${subcommand}':`), `${command} is missing dispatch`);
|
|
67
69
|
}
|
|
68
70
|
});
|
|
71
|
+
|
|
72
|
+
test('CLI help exposes the dispatchable read-only handoff context boundary', () => {
|
|
73
|
+
const bin = readFileSync(BIN_PATH, 'utf8');
|
|
74
|
+
assert.match(bin, /case 'handoff-context':/);
|
|
75
|
+
});
|
package/src/cli/install.mjs
CHANGED
|
@@ -163,6 +163,62 @@ export function isThroughlineCodexPostToolUseCommand(command) {
|
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
const CODEX_HOOK_EVENTS = new Set(['stop', 'user-prompt-submit', 'post-tool-use']);
|
|
167
|
+
|
|
168
|
+
function tokenizeCommand(command) {
|
|
169
|
+
const tokens = [];
|
|
170
|
+
const pattern = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
|
|
171
|
+
let match;
|
|
172
|
+
while ((match = pattern.exec(command)) !== null) {
|
|
173
|
+
// quoteCommandPath は `"` だけを escape する。Windows path の `\` は温存する。
|
|
174
|
+
if (match[1] !== undefined) tokens.push(match[1].replace(/\\"/g, '"'));
|
|
175
|
+
else if (match[2] !== undefined) tokens.push(match[2]);
|
|
176
|
+
else tokens.push(match[3]);
|
|
177
|
+
}
|
|
178
|
+
return tokens;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 絶対パス型の Codex hook command を {nodePath, scriptPath, event} へ分解する。
|
|
183
|
+
* 旧 PATH 解決型 (`throughline codex-hook stop`) など、この形でないものは null。
|
|
184
|
+
*/
|
|
185
|
+
export function parseCodexHookCommand(command) {
|
|
186
|
+
if (typeof command !== 'string') return null;
|
|
187
|
+
const tokens = tokenizeCommand(command.replace(/^&\s+/, ''));
|
|
188
|
+
if (tokens.length !== 4) return null;
|
|
189
|
+
const [nodePath, scriptPath, subcommand, event] = tokens;
|
|
190
|
+
if (subcommand !== 'codex-hook' || !CODEX_HOOK_EVENTS.has(event)) return null;
|
|
191
|
+
if (!scriptPath.endsWith('throughline.mjs')) return null;
|
|
192
|
+
return { nodePath, scriptPath, event };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 登録済み hook command が期待値と同じ実行体を指すかを判定する。
|
|
197
|
+
*
|
|
198
|
+
* node の表記は呼び出し元の PATH で変わる (`resolveCodexHookNodePath` は PATH 上に
|
|
199
|
+
* 同一 node があればその表記を、無ければ `process.execPath` を返す)。launchd の
|
|
200
|
+
* ような最小 PATH から診断すると `/opt/homebrew/bin/node` で登録された正規 hook が
|
|
201
|
+
* `/opt/homebrew/Cellar/node/<ver>/bin/node` と文字列比較され、正しい登録が legacy
|
|
202
|
+
* と誤判定される。実体 (realpath) の同一性で比較してこれを防ぐ。realpath を解決
|
|
203
|
+
* できない場合は同一とみなさない。
|
|
204
|
+
*/
|
|
205
|
+
export function isEquivalentCodexHookCommand(actual, expected, { realpath = realpathSync.native } = {}) {
|
|
206
|
+
if (typeof actual !== 'string' || typeof expected !== 'string') return false;
|
|
207
|
+
if (actual === expected) return true;
|
|
208
|
+
const left = parseCodexHookCommand(actual);
|
|
209
|
+
const right = parseCodexHookCommand(expected);
|
|
210
|
+
if (!left || !right || left.event !== right.event) return false;
|
|
211
|
+
return isSameExecutablePath(left.scriptPath, right.scriptPath, realpath) &&
|
|
212
|
+
isSameExecutablePath(left.nodePath, right.nodePath, realpath);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isSameExecutablePath(a, b, realpath) {
|
|
216
|
+
if (a === b) return true;
|
|
217
|
+
const left = safeRealpath(a, realpath);
|
|
218
|
+
const right = safeRealpath(b, realpath);
|
|
219
|
+
return left !== null && right !== null && left === right;
|
|
220
|
+
}
|
|
221
|
+
|
|
166
222
|
function createCodexHooks() {
|
|
167
223
|
return {
|
|
168
224
|
UserPromptSubmit: {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
buildCodexPostToolUseHookCommand,
|
|
9
9
|
buildCodexStopHookCommand,
|
|
10
10
|
buildCodexUserPromptSubmitHookCommand,
|
|
11
|
+
isEquivalentCodexHookCommand,
|
|
11
12
|
isThroughlineCodexHookCommand,
|
|
13
|
+
parseCodexHookCommand,
|
|
12
14
|
resolveCodexHookNodePath,
|
|
13
15
|
run,
|
|
14
16
|
resolveThroughlineOnPath,
|
|
@@ -580,6 +582,65 @@ test('resolveCodexHookNodePath: falls back to process execPath when PATH node is
|
|
|
580
582
|
assert.equal(result, execPath);
|
|
581
583
|
});
|
|
582
584
|
|
|
585
|
+
test('parseCodexHookCommand: 絶対パス型を分解し、旧 PATH 解決型と別コマンドは null', () => {
|
|
586
|
+
assert.deepEqual(
|
|
587
|
+
parseCodexHookCommand('/opt/homebrew/bin/node /pkg/bin/throughline.mjs codex-hook stop'),
|
|
588
|
+
{ nodePath: '/opt/homebrew/bin/node', scriptPath: '/pkg/bin/throughline.mjs', event: 'stop' },
|
|
589
|
+
);
|
|
590
|
+
assert.deepEqual(
|
|
591
|
+
parseCodexHookCommand('& "C:\\node.exe" "C:\\pkg\\bin\\throughline.mjs" codex-hook post-tool-use'),
|
|
592
|
+
{ nodePath: 'C:\\node.exe', scriptPath: 'C:\\pkg\\bin\\throughline.mjs', event: 'post-tool-use' },
|
|
593
|
+
);
|
|
594
|
+
assert.equal(parseCodexHookCommand('throughline codex-hook stop'), null);
|
|
595
|
+
assert.equal(parseCodexHookCommand('/bin/node /pkg/bin/throughline.mjs codex-hook rewind'), null);
|
|
596
|
+
assert.equal(parseCodexHookCommand('/bin/node /pkg/bin/other.mjs codex-hook stop'), null);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('isEquivalentCodexHookCommand: 同一 node 実体を指す別表記を legacy と誤判定しない', () => {
|
|
600
|
+
// launchd の最小 PATH から診断すると expected 側だけ Cellar 実体表記になる。
|
|
601
|
+
const registered = '/opt/homebrew/bin/node /pkg/bin/throughline.mjs codex-hook user-prompt-submit';
|
|
602
|
+
const expected = '/opt/homebrew/Cellar/node/26.5.0/bin/node /pkg/bin/throughline.mjs codex-hook user-prompt-submit';
|
|
603
|
+
const realpath = (p) => (p.endsWith('node') ? '/real/node' : p);
|
|
604
|
+
assert.equal(isEquivalentCodexHookCommand(registered, expected, { realpath }), true);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('isEquivalentCodexHookCommand: 別 install / 別 event / 旧形式は同一とみなさない', () => {
|
|
608
|
+
const realpath = (p) => p;
|
|
609
|
+
assert.equal(
|
|
610
|
+
isEquivalentCodexHookCommand(
|
|
611
|
+
'/bin/node /old/bin/throughline.mjs codex-hook stop',
|
|
612
|
+
'/bin/node /new/bin/throughline.mjs codex-hook stop',
|
|
613
|
+
{ realpath },
|
|
614
|
+
),
|
|
615
|
+
false,
|
|
616
|
+
);
|
|
617
|
+
assert.equal(
|
|
618
|
+
isEquivalentCodexHookCommand(
|
|
619
|
+
'/bin/node /pkg/bin/throughline.mjs codex-hook stop',
|
|
620
|
+
'/bin/node /pkg/bin/throughline.mjs codex-hook post-tool-use',
|
|
621
|
+
{ realpath },
|
|
622
|
+
),
|
|
623
|
+
false,
|
|
624
|
+
);
|
|
625
|
+
assert.equal(
|
|
626
|
+
isEquivalentCodexHookCommand(
|
|
627
|
+
'throughline codex-hook stop',
|
|
628
|
+
'/bin/node /pkg/bin/throughline.mjs codex-hook stop',
|
|
629
|
+
{ realpath },
|
|
630
|
+
),
|
|
631
|
+
false,
|
|
632
|
+
);
|
|
633
|
+
// realpath を解決できない時は同一とみなさない(暗黙の緩和をしない)
|
|
634
|
+
assert.equal(
|
|
635
|
+
isEquivalentCodexHookCommand(
|
|
636
|
+
'/a/node /pkg/bin/throughline.mjs codex-hook stop',
|
|
637
|
+
'/b/node /pkg/bin/throughline.mjs codex-hook stop',
|
|
638
|
+
{ realpath: (p) => { throw new Error(`missing: ${p}`); } },
|
|
639
|
+
),
|
|
640
|
+
false,
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
|
|
583
644
|
test('install is idempotent: second run keeps exactly one tl.md and one hook entry', async () => {
|
|
584
645
|
const home = makeTempHome();
|
|
585
646
|
if (home.resolved !== home.dir) {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
// GitHub hosted Windows runners can spend more than 15 seconds starting the
|
|
4
|
+
// inbox Windows PowerShell process while the Node matrix is contended. This is
|
|
5
|
+
// a hang guard for a fixture boundary, not a latency assertion.
|
|
6
|
+
const WINDOWS_ACL_FIXTURE_TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
3
8
|
export function applyWindowsPrivateAcl(path, directory = false) {
|
|
4
9
|
if (process.platform !== 'win32') return;
|
|
5
10
|
const script = String.raw`
|
|
@@ -14,10 +19,10 @@ $acl.AddAccessRule($rule)
|
|
|
14
19
|
if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO.File]::SetAccessControl($target,$acl)}
|
|
15
20
|
`;
|
|
16
21
|
const result = spawnSync('powershell.exe', [
|
|
17
|
-
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
22
|
+
'-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script,
|
|
18
23
|
], {
|
|
19
24
|
encoding: 'utf8',
|
|
20
|
-
timeout:
|
|
25
|
+
timeout: WINDOWS_ACL_FIXTURE_TIMEOUT_MS,
|
|
21
26
|
windowsHide: true,
|
|
22
27
|
env: {
|
|
23
28
|
...process.env,
|
|
@@ -43,10 +48,10 @@ $rule=$rules[0]
|
|
|
43
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}
|
|
44
49
|
`;
|
|
45
50
|
const result = spawnSync('powershell.exe', [
|
|
46
|
-
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
51
|
+
'-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script,
|
|
47
52
|
], {
|
|
48
53
|
encoding: 'utf8',
|
|
49
|
-
timeout:
|
|
54
|
+
timeout: WINDOWS_ACL_FIXTURE_TIMEOUT_MS,
|
|
50
55
|
windowsHide: true,
|
|
51
56
|
env: {
|
|
52
57
|
...process.env,
|