throughline 0.10.1 → 0.10.3
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 +33 -1
- package/README.ja.md +9 -3
- package/README.md +9 -2
- package/docs/00_overview.md +1 -0
- package/docs/16_readonly_handoff_context_plan.md +2 -2
- package/docs/adr/0022-cursor-host-capture.md +39 -0
- package/package.json +9 -8
- package/src/auditor-context.mjs +3 -3
- package/src/cli/auditor-context.mjs +2 -1
- package/src/cli/doctor.mjs +7 -6
- package/src/cli/install.mjs +91 -0
- package/src/cli/install.test.mjs +57 -0
- package/src/cli/trim.mjs +3 -2
- package/src/codex-auto-refresh.mjs +3 -3
- package/src/codex-sidecar.mjs +2 -1
- package/src/completed-turn-receipts.mjs +5 -5
- package/src/hosts/claude.mjs +1 -0
- package/src/hosts/codex.mjs +1 -0
- package/src/hosts/cursor.mjs +128 -0
- package/src/hosts/cursor.test.mjs +104 -0
- package/src/hosts/grok.mjs +1 -0
- package/src/hosts/identity.mjs +20 -3
- package/src/hosts/identity.test.mjs +27 -4
- package/src/hosts/index.mjs +11 -2
- package/src/os/app-dirs.mjs +25 -0
- package/src/os/paths.mjs +4 -0
- package/src/runtime-error-store.mjs +6 -7
- package/src/session-start.mjs +24 -1
- package/src/transcript-reader-cursor.test.mjs +43 -0
- package/src/transcript-reader.mjs +14 -7
- package/src/trim-model.mjs +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,36 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.10.3] — 2026-08-24
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Cursor を first-class hook host にする(工場 Cursor harness campaign Wave 6)。
|
|
18
|
+
- envelope は `hook_event_name=sessionStart|beforeSubmitPrompt|stop` と
|
|
19
|
+
`conversation_id` / `cursor_version`。session id は `cursor:<uuid>`。
|
|
20
|
+
- L2 は payload の `transcript_path`、無ければ
|
|
21
|
+
`~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl`。
|
|
22
|
+
- `throughline install` は `~/.cursor/hooks.json` へ絶対 `node` +
|
|
23
|
+
`bin/throughline.mjs` の sessionStart / beforeSubmitPrompt / stop を upsert
|
|
24
|
+
する。工場 hook(`cursor-*-hook`)は残す。bare `throughline` は書かない。
|
|
25
|
+
- beforeSubmitPrompt は continue のみなので、引き継ぎ注入は sessionStart の
|
|
26
|
+
`additional_context`。`/tl` 後継の自動起動はしない。
|
|
27
|
+
- Claude / Codex / Grok 契約は変えない。
|
|
28
|
+
|
|
29
|
+
## [0.10.2] — 2026-08-24
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- 挙動不変のOS/harness層集約リファクタ(harness用語統一campaignの分離規約):
|
|
34
|
+
- LOCALAPPDATA / XDG_CONFIG_HOME / XDG_STATE_HOME のベースディレクトリ組み立てを
|
|
35
|
+
新設`src/os/app-dirs.mjs`へ一本化(runtime-error-store / completed-turn-receipts
|
|
36
|
+
に3重実装されていた)。
|
|
37
|
+
- hostリテラル比較(`'claude'`/`'codex'`)6ファイルを`src/hosts/identity.mjs`の
|
|
38
|
+
定数importへ統一し、「識別の唯一の正本」宣言と実装を一致させた。
|
|
39
|
+
- codex-auto-refreshの独自パス正規化を`src/os/paths.mjs`の
|
|
40
|
+
`foldPathCaseForPlatform`へ、codex-sidecarのwin32判定を同`isWin32Platform`へ委譲。
|
|
41
|
+
- `hosts/identity.mjs`の分類語をvendorからharnessへ更新(用語のみ)。
|
|
42
|
+
|
|
13
43
|
## [0.10.1] — 2026-08-23
|
|
14
44
|
|
|
15
45
|
### Changed
|
|
@@ -1290,7 +1320,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1290
1320
|
|
|
1291
1321
|
---
|
|
1292
1322
|
|
|
1293
|
-
[Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.
|
|
1323
|
+
[Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.3...HEAD
|
|
1324
|
+
[0.10.3]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.2...v0.10.3
|
|
1325
|
+
[0.10.2]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.1...v0.10.2
|
|
1294
1326
|
[0.10.1]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.0...v0.10.1
|
|
1295
1327
|
[0.10.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.9.1...v0.10.0
|
|
1296
1328
|
[0.9.1]: https://github.com/kitepon-rgb/Throughline/compare/v0.9.0...v0.9.1
|
package/README.ja.md
CHANGED
|
@@ -39,8 +39,14 @@ throughline install # hook / Codex skill / VS Code monitor task を登録
|
|
|
39
39
|
`/clear` を経由しない新規 chat / VS Code 再起動では `/tl` で前任を指名できる。
|
|
40
40
|
|
|
41
41
|
Grok Desktop も first-class host である。`throughline install` は
|
|
42
|
-
`~/.grok/hooks/throughline.json` を書く。Grok
|
|
43
|
-
Terminal
|
|
42
|
+
`~/.grok/hooks/throughline.json` を書く。Grok では `/tl` は今の窓へ注入せず、
|
|
43
|
+
新しい Terminal 席を立てる。
|
|
44
|
+
|
|
45
|
+
Cursor も first-class host である。`throughline install` は `~/.cursor/hooks.json`
|
|
46
|
+
へ sessionStart / beforeSubmitPrompt / stop を upsert し、工場 hook は残す。
|
|
47
|
+
capture は Cursor の `agent-transcripts` jsonl。注入は sessionStart の
|
|
48
|
+
`additional_context`。`/tl` 後継の自動起動はしない。
|
|
49
|
+
[ADR 0022](docs/adr/0022-cursor-host-capture.md) を正とする。
|
|
44
50
|
|
|
45
51
|
<details>
|
|
46
52
|
<summary><b>Grok も併用する場合</b> Grok hooks も登録される — クリックで詳細</summary>
|
|
@@ -390,7 +396,7 @@ throughline handoff-context --session codex:<thread-id> --json
|
|
|
390
396
|
成功時の`throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
|
|
391
397
|
`context`はSessionStartと同じ予算付き継承文脈で、DB作成・migration・baton消費・session merge・
|
|
392
398
|
latest session推測・`sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
|
|
393
|
-
別
|
|
399
|
+
別harness portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
|
|
394
400
|
|
|
395
401
|
スラッシュコマンド (Claude Code 内でユーザーが叩く):
|
|
396
402
|
|
package/README.md
CHANGED
|
@@ -43,6 +43,13 @@ Grok Desktop is also a first-class host. `throughline install` writes
|
|
|
43
43
|
`~/.grok/hooks/throughline.json`. On Grok, `/tl` does not inject into the
|
|
44
44
|
current window — it starts a new Terminal seat. See below.
|
|
45
45
|
|
|
46
|
+
Cursor is also a first-class host. `throughline install` upserts
|
|
47
|
+
`sessionStart` / `beforeSubmitPrompt` / `stop` into `~/.cursor/hooks.json` and
|
|
48
|
+
leaves factory hooks in place. Capture reads Cursor `agent-transcripts` jsonl.
|
|
49
|
+
Handoff injection uses `sessionStart` `additional_context`. `/tl` does not
|
|
50
|
+
auto-launch a successor Cursor chat — the next new conversation drinks the
|
|
51
|
+
baton. See [ADR 0022](docs/adr/0022-cursor-host-capture.md).
|
|
52
|
+
|
|
46
53
|
<details>
|
|
47
54
|
<summary><b>Also using Codex?</b> Global install registers Codex hooks too — click for details.</summary>
|
|
48
55
|
|
|
@@ -834,7 +841,7 @@ local-only contracts.
|
|
|
834
841
|
| `throughline runtime-errors reopen <fingerprint> --json` | Explicitly reopen a resolved aggregate without fabricating a new occurrence |
|
|
835
842
|
| `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
|
|
836
843
|
| `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
|
|
837
|
-
| `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-
|
|
844
|
+
| `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-harness context |
|
|
838
845
|
| `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 |
|
|
839
846
|
| `throughline codex-capture --codex-thread-id <id>` | Capture active Codex rollout turns into a `codex:<thread_id>` DB session |
|
|
840
847
|
| `throughline codex-summarize --session codex:<id>` | Summarize captured Codex L2 into L1 with the Codex CLI backend |
|
|
@@ -872,7 +879,7 @@ The successful `throughline.handoff_context.v1` object contains only `schema`,
|
|
|
872
879
|
inheritance text used by SessionStart. The command does not create or migrate a
|
|
873
880
|
database, consume a baton, merge sessions, infer a latest session, change
|
|
874
881
|
`sessions.merged_into`, or reassign L1/L2/L3 rows. AIterm uses this boundary for
|
|
875
|
-
its optional cross-
|
|
882
|
+
its optional cross-harness portable fork; the Observer feed is a separate
|
|
876
883
|
completed-turn projection and is not a substitute.
|
|
877
884
|
|
|
878
885
|
Slash commands (invoked by the user in Claude Code):
|
package/docs/00_overview.md
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
- [ADR 0003](adr/0003-observer-completed-chain-cursor.md): Observer cursorをhost固有のcompleted pair chainとprefix検証へ束縛する。
|
|
41
41
|
- [ADR 0020](adr/0020-windows-ci-release-latency.md): Windows ACL契約を維持し、境界fixtureと実ACL検証を分離する。
|
|
42
42
|
- [ADR 0021](adr/0021-grok-host-capture.md): Grok を first-class hook host にし、`/tl` 後の記憶再開は `grok-continue` に固定する。
|
|
43
|
+
- [ADR 0022](adr/0022-cursor-host-capture.md): Cursor を first-class hook host にし、注入は sessionStart の `additional_context`、hook は `~/.cursor/hooks.json` への upsert。
|
|
43
44
|
|
|
44
45
|
Observerの公開境界は`throughline observer-read`/`throughline observer-wait`のJSON-only CLIである。
|
|
45
46
|
ThroughlineはClaude Stop receiptとCodex rolloutの`task_complete`だけからcompleted cursorを構築し、
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
通常 handoff が使う `buildBudgetedResumeContext` の出力を、DB 所有権を変えずにローカルプログラムが
|
|
6
6
|
取得できる read-only CLI として公開する。最初の consumer は AIterm の portable fork だが、I/F は
|
|
7
|
-
特定 consumer や
|
|
7
|
+
特定 consumer や harness に依存させない。
|
|
8
8
|
|
|
9
9
|
実行 ToDo、依存、状態、完了証拠の正本は Lattice plan `readonly-handoff-context` とする。
|
|
10
10
|
|
|
11
11
|
## 完了
|
|
12
12
|
|
|
13
13
|
2026-08-04に`throughline@0.9.0`としてnpm、tag、GitHub Release、global installまで公開した。
|
|
14
|
-
focused契約testと全回帰は729 pass/1 skip/0 fail。AIterm v0.23.0の代表cross-
|
|
14
|
+
focused契約testと全回帰は729 pass/1 skip/0 fail。AIterm v0.23.0の代表cross-harness smokeでは
|
|
15
15
|
Codex source memoryをClaudeへ注入し、前後でsource session、`sessions.merged_into`、L1/L2/L3 row所属が
|
|
16
16
|
完全一致することを確認した。公開後の現行ドキュメント全域監査は、Latticeの終端ToDoを再openして
|
|
17
17
|
README、作業者入口、配布Codex skill、docs索引、計画、CHANGELOGへ同期した。変更Markdownの
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR 0022: Treat Cursor as a first-class Throughline host
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-24
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
dotagents Wave 5 made Cursor a peer factory harness. Throughline still had only Claude / Codex / Grok adapters. Cursor parent sessions were not captured.
|
|
12
|
+
|
|
13
|
+
Cursor hook envelope is not Claude PascalCase and not Grok camelCase. Official events are `sessionStart`, `beforeSubmitPrompt` (Claude `UserPromptSubmit` 相当), and `stop`. Common fields include `conversation_id`, `workspace_roots`, `cursor_version`, and optional `transcript_path`.
|
|
14
|
+
|
|
15
|
+
L2 lives in `~/.cursor/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl` with role-first rows (`{role, message.content}`), not Claude `{type, message}`.
|
|
16
|
+
|
|
17
|
+
`beforeSubmitPrompt` stdout is `{continue}` only. `sessionStart` may return `additional_context`. Cursor has a single `~/.cursor/hooks.json`; factory hooks already occupy that file.
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
- Detect Cursor envelope (`cursor_version` or `hook_event_name` in the Cursor event set) as host=cursor.
|
|
22
|
+
- Normalize to the existing snake_case hook contract and prefix ids with `cursor:`. Strip optional `bc-` from cloud conversation ids when looking up transcripts.
|
|
23
|
+
- Prefer payload `transcript_path`. If absent, derive the agent-transcripts path. Do not invent a Claude-shaped transcript.
|
|
24
|
+
- `throughline install` upserts product hooks into `~/.cursor/hooks.json`. Keep factory / personal commands. Command is absolute `node` + `bin/throughline.mjs`. Do not write factory.json. Do not write bare `throughline`.
|
|
25
|
+
- Cursor `sessionStart` of a new composer conversation is the handoff injection mouth (`additional_context`). Consume pending there. Skip `is_background_agent`. Claude ghost-SessionStart rules stay on Claude.
|
|
26
|
+
- `/tl` writes a baton. Do not auto-launch a successor Cursor chat. The next new conversation's `sessionStart` drinks the baton.
|
|
27
|
+
- Keep Claude, Codex, and Grok adapters unchanged. Do not mix `cursor:` rows into Claude predecessor search.
|
|
28
|
+
|
|
29
|
+
## Consequences
|
|
30
|
+
|
|
31
|
+
- Cursor capture / restore / `handoff-context` reuse the existing DB after hook fire exists.
|
|
32
|
+
- Live injection in the same conversation after `/tl` is not a Cursor contract (no additional_context on `beforeSubmitPrompt`).
|
|
33
|
+
- Factory apply-cursor-config must keep Throughline commands, matching the existing personal-hook upsert.
|
|
34
|
+
|
|
35
|
+
## 現在地
|
|
36
|
+
|
|
37
|
+
公式 Cursor hooks 文書(2026-08-24)とこのMacの
|
|
38
|
+
`~/.cursor/projects/Users-kite-Developer-dotagents/agent-transcripts/<uuid>/<uuid>.jsonl`
|
|
39
|
+
を実測した。公開後の新規 Cursor session 受入は別 H。
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "throughline",
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
"version": "0.10.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"grok",
|
|
10
|
+
"cursor",
|
|
10
11
|
"hooks",
|
|
11
12
|
"context-compression",
|
|
12
13
|
"llm"
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"LICENSE"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
|
-
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.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",
|
|
44
45
|
"verify:release-commit": "node scripts/verify-release-commit.mjs",
|
|
45
46
|
"prepublishOnly": "npm run verify:release-commit && npm test"
|
|
46
47
|
},
|
package/src/auditor-context.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
5
|
import { buildBodyRowsFromActiveTurns } from './codex-capture.mjs';
|
|
6
|
-
import { isCodexSessionId } from './hosts/identity.mjs';
|
|
6
|
+
import { CLAUDE_HOST, CODEX_HOST, isCodexSessionId } from './hosts/identity.mjs';
|
|
7
7
|
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
8
8
|
import { getLogicalTurnGroups } from './transcript-reader.mjs';
|
|
9
9
|
import { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
|
|
@@ -24,7 +24,7 @@ export function defaultAuditorContextDbPath() {
|
|
|
24
24
|
export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessionId } = {}) {
|
|
25
25
|
assertNonEmptyString(transcriptPath, 'transcriptPath');
|
|
26
26
|
assertNonEmptyString(sessionId, 'sessionId');
|
|
27
|
-
if (host ===
|
|
27
|
+
if (host === CLAUDE_HOST) {
|
|
28
28
|
const latest = getLogicalTurnGroups(transcriptPath).at(-1);
|
|
29
29
|
if (!latest) return null;
|
|
30
30
|
return {
|
|
@@ -34,7 +34,7 @@ export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessio
|
|
|
34
34
|
expectedAssistantSha256: hashAuditorBody(latest.representative.content),
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
-
if (host ===
|
|
37
|
+
if (host === CODEX_HOST) {
|
|
38
38
|
const parsed = parseCodexRolloutFile(transcriptPath, { includeInFlightTurn: false });
|
|
39
39
|
const rows = buildBodyRowsFromActiveTurns(parsed.activeTurns, { sessionId, now: 0 });
|
|
40
40
|
const latestTurnNumber = rows.reduce((max, row) => Math.max(max, row.turnNumber), 0);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLAUDE_HOST, CODEX_HOST } from '../hosts/identity.mjs';
|
|
1
2
|
import {
|
|
2
3
|
AUDITOR_CONTEXT_SCHEMA,
|
|
3
4
|
deriveAuditorFreshnessExpectation,
|
|
@@ -53,7 +54,7 @@ export function parseArgs(argv = []) {
|
|
|
53
54
|
else if (arg === '--max-body-chars') out.maxBodyChars = parsePositiveInteger(value);
|
|
54
55
|
else if (arg === '--max-total-chars') out.maxTotalChars = parsePositiveInteger(value);
|
|
55
56
|
else if (arg === '--db') out.dbPath = value;
|
|
56
|
-
else if (arg === '--host' && (value ===
|
|
57
|
+
else if (arg === '--host' && (value === CLAUDE_HOST || value === CODEX_HOST)) out.host = value;
|
|
57
58
|
else if (arg === '--transcript') out.transcriptPath = value;
|
|
58
59
|
else throw new TypeError('unknown option');
|
|
59
60
|
}
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { existsSync, accessSync, readFileSync, constants, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { CODEX_HOST } from '../hosts/identity.mjs';
|
|
17
18
|
import { join, dirname } from 'node:path';
|
|
18
19
|
import { homedir } from 'node:os';
|
|
19
20
|
import { execSync } from 'node:child_process';
|
|
@@ -302,9 +303,9 @@ function runTrimDiagnosis(
|
|
|
302
303
|
) {
|
|
303
304
|
const info = describeTrimHost(host);
|
|
304
305
|
const codexIdentity =
|
|
305
|
-
info.host ===
|
|
306
|
+
info.host === CODEX_HOST ? resolveCodexThreadIdentity({ codexThreadId: null }, env) : null;
|
|
306
307
|
const hostPrimitiveDiagnosis =
|
|
307
|
-
info.host ===
|
|
308
|
+
info.host === CODEX_HOST ? readCodexHostPrimitiveDiagnosis({ env, auditRunner }) : null;
|
|
308
309
|
console.log(`${BOLD}[Trim]${RESET}\n`);
|
|
309
310
|
console.log(` host: ${info.host}`);
|
|
310
311
|
console.log(` default keep-recent: ${DEFAULT_TRIM_KEEP_RECENT}`);
|
|
@@ -330,19 +331,19 @@ function runTrimDiagnosis(
|
|
|
330
331
|
}
|
|
331
332
|
console.log('');
|
|
332
333
|
console.log(' dry-run command:');
|
|
333
|
-
if (info.host ===
|
|
334
|
+
if (info.host === CODEX_HOST && !codexIdentity?.codexThreadId) {
|
|
334
335
|
console.log(' throughline trim --dry-run --host codex --codex-thread-id <id>');
|
|
335
336
|
console.log(' throughline trim --preflight --host codex --codex-thread-id <id>');
|
|
336
|
-
} else if (info.host ===
|
|
337
|
+
} else if (info.host === CODEX_HOST) {
|
|
337
338
|
console.log(' throughline trim --dry-run --host codex');
|
|
338
339
|
console.log(' throughline trim --preflight --host codex');
|
|
339
340
|
} else {
|
|
340
341
|
console.log(` throughline trim --dry-run --host ${info.host}`);
|
|
341
342
|
}
|
|
342
|
-
if (info.host ===
|
|
343
|
+
if (info.host === CODEX_HOST) {
|
|
343
344
|
console.log(' throughline trim --execute --host codex');
|
|
344
345
|
}
|
|
345
|
-
if (info.host ===
|
|
346
|
+
if (info.host === CODEX_HOST) {
|
|
346
347
|
const sessionId = codexIdentity?.codexThreadId
|
|
347
348
|
? `codex:${codexIdentity.codexThreadId}`
|
|
348
349
|
: 'codex:<thread-id>';
|
package/src/cli/install.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* script path で登録する。
|
|
12
12
|
* Grok-facing hook も Desktop の GUI PATH に throughline が無いため、同じ絶対
|
|
13
13
|
* node + CLI script path で ~/.grok/hooks/throughline.json に書く。
|
|
14
|
+
* Cursor-facing hook は ~/.cursor/hooks.json へ upsert する(工場 hook は残す)。
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
@@ -27,6 +28,7 @@ const CODEX_SKILL_NAMES = ['throughline'];
|
|
|
27
28
|
const CODEX_HOOKS_RELATIVE_PATH = ['.codex', 'hooks.json'];
|
|
28
29
|
const CODEX_CONFIG_RELATIVE_PATH = ['.codex', 'config.toml'];
|
|
29
30
|
const GROK_HOOKS_RELATIVE_PATH = ['.grok', 'hooks', 'throughline.json'];
|
|
31
|
+
const CURSOR_HOOKS_RELATIVE_PATH = ['.cursor', 'hooks.json'];
|
|
30
32
|
|
|
31
33
|
// Throughline が管理する hook コマンド一覧
|
|
32
34
|
// schema v4 以降: PostToolUse (capture-tool) は廃止。Stop 内で L2/L3 を一括処理する。
|
|
@@ -297,6 +299,16 @@ export function buildGrokHookCommand(subcommand, {
|
|
|
297
299
|
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} ${subcommand}`;
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
export function buildCursorHookCommand(subcommand, options = {}) {
|
|
303
|
+
return buildGrokHookCommand(subcommand, options);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function isThroughlineCursorHookCommand(command) {
|
|
307
|
+
return typeof command === 'string'
|
|
308
|
+
&& command.includes('throughline.mjs')
|
|
309
|
+
&& /\b(session-start|prompt-submit|process-turn)\b/.test(command);
|
|
310
|
+
}
|
|
311
|
+
|
|
300
312
|
export function createGrokHooksFile(options = {}) {
|
|
301
313
|
return {
|
|
302
314
|
hooks: {
|
|
@@ -334,6 +346,74 @@ function uninstallGrokHooks() {
|
|
|
334
346
|
return { hooksPath, removed: 1 };
|
|
335
347
|
}
|
|
336
348
|
|
|
349
|
+
function resolveCursorHooksPath() {
|
|
350
|
+
return join(homedir(), ...CURSOR_HOOKS_RELATIVE_PATH);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function createCursorHookEntries(options = {}) {
|
|
354
|
+
return {
|
|
355
|
+
sessionStart: [
|
|
356
|
+
{ command: buildCursorHookCommand('session-start', options), timeout: 10 },
|
|
357
|
+
],
|
|
358
|
+
beforeSubmitPrompt: [
|
|
359
|
+
{ command: buildCursorHookCommand('prompt-submit', options), timeout: 30 },
|
|
360
|
+
],
|
|
361
|
+
stop: [
|
|
362
|
+
{ command: buildCursorHookCommand('process-turn', options), timeout: 300 },
|
|
363
|
+
],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readCursorHooksFile(hooksPath) {
|
|
368
|
+
if (!existsSync(hooksPath)) return { version: 1, hooks: {} };
|
|
369
|
+
const parsed = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
370
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
371
|
+
throw new Error(`${hooksPath} は object である必要があります`);
|
|
372
|
+
}
|
|
373
|
+
if (parsed.hooks == null) parsed.hooks = {};
|
|
374
|
+
if (typeof parsed.hooks !== 'object' || Array.isArray(parsed.hooks)) {
|
|
375
|
+
throw new Error(`${hooksPath} の hooks は object である必要があります`);
|
|
376
|
+
}
|
|
377
|
+
return parsed;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function installCursorHooks() {
|
|
381
|
+
const hooksPath = resolveCursorHooksPath();
|
|
382
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
383
|
+
const current = readCursorHooksFile(hooksPath);
|
|
384
|
+
current.version = 1;
|
|
385
|
+
const wanted = createCursorHookEntries();
|
|
386
|
+
for (const [event, entries] of Object.entries(wanted)) {
|
|
387
|
+
const existing = Array.isArray(current.hooks[event]) ? current.hooks[event] : [];
|
|
388
|
+
const kept = existing.filter((entry) => !isThroughlineCursorHookCommand(entry?.command));
|
|
389
|
+
current.hooks[event] = [...kept, ...entries];
|
|
390
|
+
}
|
|
391
|
+
writeFileSync(hooksPath, `${JSON.stringify(current, null, 2)}\n`);
|
|
392
|
+
return { hooksPath };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function uninstallCursorHooks() {
|
|
396
|
+
const hooksPath = resolveCursorHooksPath();
|
|
397
|
+
if (!existsSync(hooksPath)) return { hooksPath, removed: 0 };
|
|
398
|
+
const current = readCursorHooksFile(hooksPath);
|
|
399
|
+
let removed = 0;
|
|
400
|
+
for (const [event, list] of Object.entries(current.hooks ?? {})) {
|
|
401
|
+
if (!Array.isArray(list)) continue;
|
|
402
|
+
const kept = list.filter((entry) => {
|
|
403
|
+
if (isThroughlineCursorHookCommand(entry?.command)) {
|
|
404
|
+
removed += 1;
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
return true;
|
|
408
|
+
});
|
|
409
|
+
if (kept.length > 0) current.hooks[event] = kept;
|
|
410
|
+
else delete current.hooks[event];
|
|
411
|
+
}
|
|
412
|
+
if (removed === 0) return { hooksPath, removed: 0 };
|
|
413
|
+
writeFileSync(hooksPath, `${JSON.stringify(current, null, 2)}\n`);
|
|
414
|
+
return { hooksPath, removed };
|
|
415
|
+
}
|
|
416
|
+
|
|
337
417
|
function installSlashCommands(commandsDir) {
|
|
338
418
|
if (!existsSync(SLASH_COMMANDS_SRC)) {
|
|
339
419
|
return { installed: [], skipped: 'source-missing' };
|
|
@@ -589,6 +669,7 @@ export async function run(args = []) {
|
|
|
589
669
|
const removedCommands = uninstallSlashCommands(commandsDir);
|
|
590
670
|
const codex = args.includes('--project') ? null : uninstallCodexHooks();
|
|
591
671
|
const grok = args.includes('--project') ? null : uninstallGrokHooks();
|
|
672
|
+
const cursor = args.includes('--project') ? null : uninstallCursorHooks();
|
|
592
673
|
const removedCodexSkills = args.includes('--project') ? [] : uninstallCodexSkills(codexSkillsDir);
|
|
593
674
|
console.log('Throughline hooks を削除しました。');
|
|
594
675
|
console.log(` ${settingsPath}`);
|
|
@@ -601,6 +682,9 @@ export async function run(args = []) {
|
|
|
601
682
|
if (grok?.removed > 0) {
|
|
602
683
|
console.log(` Grok hooks 削除: ${grok.removed} (${grok.hooksPath})`);
|
|
603
684
|
}
|
|
685
|
+
if (cursor?.removed > 0) {
|
|
686
|
+
console.log(` Cursor hooks 削除: ${cursor.removed} (${cursor.hooksPath})`);
|
|
687
|
+
}
|
|
604
688
|
if (removedCodexSkills.length > 0) {
|
|
605
689
|
console.log(` Codex skills 削除: ${removedCodexSkills.join(', ')} (${codexSkillsDir})`);
|
|
606
690
|
}
|
|
@@ -624,6 +708,7 @@ export async function run(args = []) {
|
|
|
624
708
|
const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
|
|
625
709
|
const codex = args.includes('--project') ? null : installCodexHooks();
|
|
626
710
|
const grok = args.includes('--project') ? null : installGrokHooks();
|
|
711
|
+
const cursor = args.includes('--project') ? null : installCursorHooks();
|
|
627
712
|
const codexSkills = args.includes('--project') ? { installed: [], skipped: null } : installCodexSkills(codexSkillsDir);
|
|
628
713
|
const monitorTask = ensureMonitorTaskFile({
|
|
629
714
|
cwd: process.cwd(),
|
|
@@ -643,6 +728,9 @@ export async function run(args = []) {
|
|
|
643
728
|
if (grok) {
|
|
644
729
|
console.log(` ${grok.hooksPath}`);
|
|
645
730
|
}
|
|
731
|
+
if (cursor) {
|
|
732
|
+
console.log(` ${cursor.hooksPath}`);
|
|
733
|
+
}
|
|
646
734
|
console.log('');
|
|
647
735
|
console.log('有効な hooks:');
|
|
648
736
|
console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
|
|
@@ -656,6 +744,9 @@ export async function run(args = []) {
|
|
|
656
744
|
if (grok) {
|
|
657
745
|
console.log(' Grok SessionStart / UserPromptSubmit / Stop → ~/.grok/hooks/throughline.json');
|
|
658
746
|
}
|
|
747
|
+
if (cursor) {
|
|
748
|
+
console.log(' Cursor sessionStart / beforeSubmitPrompt / stop → ~/.cursor/hooks.json(工場hookは残す)');
|
|
749
|
+
}
|
|
659
750
|
console.log('');
|
|
660
751
|
if (installedCommands.length > 0) {
|
|
661
752
|
console.log(`slash commands を配置しました: ${installedCommands.map(n => '/' + n.replace(/\.md$/, '')).join(', ')}`);
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
buildCodexUserPromptSubmitHookCommand,
|
|
11
11
|
buildGrokHookCommand,
|
|
12
12
|
createGrokHooksFile,
|
|
13
|
+
createCursorHookEntries,
|
|
13
14
|
isEquivalentCodexHookCommand,
|
|
14
15
|
isThroughlineCodexHookCommand,
|
|
16
|
+
isThroughlineCursorHookCommand,
|
|
15
17
|
parseCodexHookCommand,
|
|
16
18
|
resolveCodexHookNodePath,
|
|
17
19
|
run,
|
|
@@ -92,6 +94,28 @@ test('global install copies Throughline slash commands to ~/.claude/commands/',
|
|
|
92
94
|
assert.match(command, /throughline\.mjs/);
|
|
93
95
|
assert.doesNotMatch(command, /^throughline /);
|
|
94
96
|
}
|
|
97
|
+
const cursorHooks = JSON.parse(readFileSync(join(home.dir, '.cursor', 'hooks.json'), 'utf8'));
|
|
98
|
+
assert.equal(cursorHooks.version, 1);
|
|
99
|
+
assert.equal(
|
|
100
|
+
cursorHooks.hooks.sessionStart[0].command,
|
|
101
|
+
createCursorHookEntries().sessionStart[0].command,
|
|
102
|
+
);
|
|
103
|
+
assert.equal(
|
|
104
|
+
cursorHooks.hooks.beforeSubmitPrompt[0].command,
|
|
105
|
+
createCursorHookEntries().beforeSubmitPrompt[0].command,
|
|
106
|
+
);
|
|
107
|
+
assert.equal(
|
|
108
|
+
cursorHooks.hooks.stop[0].command,
|
|
109
|
+
createCursorHookEntries().stop[0].command,
|
|
110
|
+
);
|
|
111
|
+
for (const command of [
|
|
112
|
+
cursorHooks.hooks.sessionStart[0].command,
|
|
113
|
+
cursorHooks.hooks.beforeSubmitPrompt[0].command,
|
|
114
|
+
cursorHooks.hooks.stop[0].command,
|
|
115
|
+
]) {
|
|
116
|
+
assert.equal(isThroughlineCursorHookCommand(command), true);
|
|
117
|
+
assert.doesNotMatch(command, /^throughline /);
|
|
118
|
+
}
|
|
95
119
|
} finally {
|
|
96
120
|
unsilence();
|
|
97
121
|
home.restore();
|
|
@@ -192,6 +216,39 @@ test('Grok hook commands are absolute node + throughline.mjs on every platform',
|
|
|
192
216
|
assert.equal(file.hooks.Stop[0].hooks[0].async, true);
|
|
193
217
|
});
|
|
194
218
|
|
|
219
|
+
test('Cursor install upserts product hooks and keeps factory commands', async () => {
|
|
220
|
+
const home = makeTempHome();
|
|
221
|
+
if (home.resolved !== home.dir) {
|
|
222
|
+
home.restore();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const unsilence = silence();
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(join(home.dir, '.cursor'), { recursive: true });
|
|
228
|
+
writeFileSync(join(home.dir, '.cursor', 'hooks.json'), `${JSON.stringify({
|
|
229
|
+
version: 1,
|
|
230
|
+
hooks: {
|
|
231
|
+
sessionStart: [{ command: '/tmp/cursor-todo-gate-hook session-start', timeout: 10 }],
|
|
232
|
+
},
|
|
233
|
+
}, null, 2)}\n`);
|
|
234
|
+
await run([]);
|
|
235
|
+
const installed = JSON.parse(readFileSync(join(home.dir, '.cursor', 'hooks.json'), 'utf8'));
|
|
236
|
+
assert.equal(installed.hooks.sessionStart[0].command, '/tmp/cursor-todo-gate-hook session-start');
|
|
237
|
+
assert.equal(isThroughlineCursorHookCommand(installed.hooks.sessionStart[1].command), true);
|
|
238
|
+
assert.equal(isThroughlineCursorHookCommand(installed.hooks.beforeSubmitPrompt[0].command), true);
|
|
239
|
+
assert.equal(isThroughlineCursorHookCommand(installed.hooks.stop[0].command), true);
|
|
240
|
+
await run(['--uninstall']);
|
|
241
|
+
const after = JSON.parse(readFileSync(join(home.dir, '.cursor', 'hooks.json'), 'utf8'));
|
|
242
|
+
assert.equal(after.hooks.sessionStart.length, 1);
|
|
243
|
+
assert.equal(after.hooks.sessionStart[0].command, '/tmp/cursor-todo-gate-hook session-start');
|
|
244
|
+
assert.equal(after.hooks.beforeSubmitPrompt, undefined);
|
|
245
|
+
assert.equal(after.hooks.stop, undefined);
|
|
246
|
+
} finally {
|
|
247
|
+
unsilence();
|
|
248
|
+
home.restore();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
195
252
|
test('Codex hook builders use the PowerShell call operator on Windows only', () => {
|
|
196
253
|
const options = {
|
|
197
254
|
nodePath: String.raw`C:\Program Files\nodejs\node.exe`,
|
package/src/cli/trim.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runCodexTrimExecution, runCodexTrimPreflight } from '../codex-app-server.mjs';
|
|
2
|
+
import { CODEX_HOST } from '../hosts/identity.mjs';
|
|
2
3
|
import {
|
|
3
4
|
buildCodexRolloutTrimSource,
|
|
4
5
|
parseCodexRolloutFile,
|
|
@@ -122,7 +123,7 @@ export async function run(args) {
|
|
|
122
123
|
const inflightMemo = parsed.memoStdin ? await readStdin() : null;
|
|
123
124
|
const db = getDb();
|
|
124
125
|
const trimSource =
|
|
125
|
-
parsed.host ===
|
|
126
|
+
parsed.host === CODEX_HOST && parsed.codexThreadId
|
|
126
127
|
? buildCodexRolloutTrimSource({
|
|
127
128
|
threadId: parsed.codexThreadId,
|
|
128
129
|
projectPath: process.cwd(),
|
|
@@ -376,7 +377,7 @@ async function runPreflight(parsed, plan) {
|
|
|
376
377
|
}
|
|
377
378
|
|
|
378
379
|
function validateCodexAction(parsed, plan, action) {
|
|
379
|
-
if (parsed.host !==
|
|
380
|
+
if (parsed.host !== CODEX_HOST) {
|
|
380
381
|
return {
|
|
381
382
|
status: `${action}-refused`,
|
|
382
383
|
reason: `${action}_requires_codex_host`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { foldPathCaseForPlatform } from './os/paths.mjs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { join, resolve } from 'node:path';
|
|
4
5
|
|
|
5
6
|
import { runCodexTrimExecution } from './codex-app-server.mjs';
|
|
@@ -509,8 +510,7 @@ function normalizeProjectPath(value) {
|
|
|
509
510
|
if (typeof value !== 'string' || value.length === 0) return '';
|
|
510
511
|
let result = resolve(value).replace(/\\/g, '/');
|
|
511
512
|
if (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1);
|
|
512
|
-
|
|
513
|
-
return result;
|
|
513
|
+
return foldPathCaseForPlatform(result);
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
function numberOrNull(value) {
|
package/src/codex-sidecar.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
2
|
+
import { isWin32Platform } from './os/paths.mjs';
|
|
2
3
|
|
|
3
4
|
export const CODEX_SIDECAR_WORKFLOWS = Object.freeze([
|
|
4
5
|
'review',
|
|
@@ -23,7 +24,7 @@ function resolveCommand({ command, env }) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function shouldShellWrapSidecarCommand(platform = process.platform) {
|
|
26
|
-
return platform
|
|
27
|
+
return isWin32Platform(platform);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function runCodexSidecarCommand(command, args, options = {}) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { CLAUDE_HOST } from './hosts/identity.mjs';
|
|
2
3
|
import {
|
|
3
4
|
chmodSync,
|
|
4
5
|
lstatSync,
|
|
@@ -8,12 +9,12 @@ import {
|
|
|
8
9
|
rmSync,
|
|
9
10
|
writeFileSync,
|
|
10
11
|
} from 'node:fs';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, join } from 'node:path';
|
|
13
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
14
14
|
import { hashAuditorBody } from './body-digest.mjs';
|
|
15
15
|
import { normalizeProjectPathForCompare } from './project-path.mjs';
|
|
16
16
|
import { applyAndVerifyWindowsAcl, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
|
|
17
|
+
import { windowsLocalAppData, xdgStateHome } from './os/app-dirs.mjs';
|
|
17
18
|
|
|
18
19
|
export const COMPLETED_TURN_RECEIPT_STORE_SCHEMA = 'throughline.completed_turn_receipts.v1';
|
|
19
20
|
export const COMPLETED_TURN_RECEIPT_SCHEMA_VERSION = '1.0';
|
|
@@ -24,11 +25,10 @@ const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.completed-turn-receipt-
|
|
|
24
25
|
|
|
25
26
|
export function defaultCompletedTurnReceiptStorePath(projectSha256, env = process.env) {
|
|
26
27
|
if (!isSha256(projectSha256)) throw new TypeError('projectSha256 must be a SHA-256 digest');
|
|
27
|
-
const home = env.HOME || env.USERPROFILE || homedir();
|
|
28
28
|
if (isWindows(env)) {
|
|
29
|
-
return join(env
|
|
29
|
+
return join(windowsLocalAppData(env), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
|
|
30
30
|
}
|
|
31
|
-
return join(env
|
|
31
|
+
return join(xdgStateHome(env), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -273,7 +273,7 @@ function validateReceipt(receipt, nextSequence) {
|
|
|
273
273
|
];
|
|
274
274
|
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) ||
|
|
275
275
|
!exactKeys(receipt, keys) || receipt.schema_version !== COMPLETED_TURN_RECEIPT_SCHEMA_VERSION ||
|
|
276
|
-
receipt.host !==
|
|
276
|
+
receipt.host !== CLAUDE_HOST || !isSha256(receipt.project_sha256) ||
|
|
277
277
|
!isIdentity(receipt.target_session_id) || !isIdentity(receipt.origin_session_id) ||
|
|
278
278
|
!isSha256(receipt.user_sha256) || !isSha256(receipt.assistant_sha256) ||
|
|
279
279
|
!Number.isSafeInteger(receipt.completed_at) || receipt.completed_at < 0 ||
|
package/src/hosts/claude.mjs
CHANGED