throughline 0.10.12 → 0.10.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,24 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.14] — 2026-09-02
14
+
15
+ ### Added
16
+
17
+ - `handoff-context --project <path> --json`で、指定project内の会話本文を持つ最新sessionを
18
+ read-onlyで選び、本文がなければ`empty`を返せるようにした。
19
+ - `--disclosure silent`を補足ファイルなしで指定できるようにし、埋込製品が短期記憶だけを
20
+ 基盤案内なしで受け取れるようにした。
21
+
22
+ ## [0.10.13] — 2026-09-02
23
+
24
+ ### Fixed
25
+
26
+ - 通常の引き継ぎ案内を最初の応答だけに限定し、project束縛済み補足から
27
+ `handoffDisclosure: "silent"`を指定したlauncherは案内を非表示にできるようにした。
28
+ - Throughline旧版がassistant本文へ付けた固定宣言行だけを次回引き継ぎ時に除外し、
29
+ ユーザーの引用と宣言後の会話本文は保持する。
30
+
13
31
  ## [0.10.12] — 2026-09-01
14
32
 
15
33
  ### Changed
@@ -1421,7 +1439,9 @@ two attempts, instrument first instead of patching again.
1421
1439
 
1422
1440
  ---
1423
1441
 
1424
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.12...HEAD
1442
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.14...HEAD
1443
+ [0.10.14]: https://github.com/kitepon/Throughline/compare/v0.10.13...v0.10.14
1444
+ [0.10.13]: https://github.com/kitepon/Throughline/compare/v0.10.12...v0.10.13
1425
1445
  [0.10.12]: https://github.com/kitepon/Throughline/compare/v0.10.11...v0.10.12
1426
1446
  [0.10.11]: https://github.com/kitepon/Throughline/compare/v0.10.10...v0.10.11
1427
1447
  [0.10.10]: https://github.com/kitepon/Throughline/compare/v0.10.9...v0.10.10
package/README.ja.md CHANGED
@@ -74,6 +74,12 @@ cwd は源セッションの `project_path` であり、呼び出し元 cwd は
74
74
  `/clear` では起動しない。aiterm / `--rules` / `--from` は使わない。
75
75
  macOS Terminal のみ。
76
76
 
77
+ 通常のhandoff-contextは、引き継いだことを最初の応答で一度だけ案内し、後続応答では
78
+ 繰り返させない。埋込製品は`handoff-context --disclosure silent`で案内を消せる。
79
+ project束縛済み補足の`handoffDisclosure: "silent"`も同じ扱いになる。旧版がassistant本文へ
80
+ 付けた固定宣言は次回引き継ぎから除外し、ユーザーの
81
+ 引用と実際の応答本文は保持する。
82
+
77
83
  新席は `~/.grok/sessions/<encodeURIComponent(cwd)>/` のトップレベル
78
84
  ディレクトリ。Desktop の Inactive 畳みは成功条件にしない。L2 が無い源
79
85
  (`merged_into` チェーンの空席など)では起動しない。新しい chat で 1〜2
@@ -390,7 +396,7 @@ v0.10.4以前には `self-update` が存在しない。該当版からの初回
390
396
  | `throughline runtime-errors snapshot --json` | boundedなlocal aggregateを読み取る(network I/Oなし) |
391
397
  | `throughline runtime-errors diagnostics --json` | collection/store状態をpathやraw errorなしで診断 |
392
398
  | `throughline handoff-preview --session <id>` | Codex 向け `throughline_handoff` JSON projection を表示 |
393
- | `throughline handoff-context --session <id> --json [--supplement-file <path>]` | SessionStartと同じ引き継ぎ文脈を取得。任意のproject束縛済み補足を、長期記憶・知識として同じ9,500字枠へ合成できる |
399
+ | `throughline handoff-context (--session <id> \| --project <path>) --json` | SessionStartと同じ引き継ぎ文脈を取得。project指定時は会話本文を持つ最新sessionを選び、`--disclosure silent`に対応し、本文がなければ`empty`を返す。session指定時はproject束縛済み補足を同じ9,500字枠へ合成できる |
394
400
  | `throughline latest-session --project <absolute-path> --json` | 指定した1プロジェクトだけを対象に直近セッションIDを読み取る。既存DBをread-onlyで開き、記録がなければ`empty`を返す |
395
401
  | `throughline grok-continue --session <id>` | handoff-context を初手 user 文にした対話 Grok 席を立てる。cwd は源の `project_path`。ready でなければ spawn しない。`--rules` なし。macOS Terminal のみ |
396
402
  | `throughline codex-sidecar-diagnostics` | この project の `codex-sidecar` diagnostics status を確認 |
@@ -423,14 +429,18 @@ throughline runtime-errors diagnostics --json
423
429
 
424
430
  ```bash
425
431
  throughline handoff-context --session codex:<thread-id> --json
432
+ throughline handoff-context --project /absolute/bot/project --json --disclosure silent
426
433
  ```
427
434
 
428
- 成功時の`throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
429
- `context`はSessionStartと同じ予算付き継承文脈である。任意の`--supplement-file <path>`には
435
+ `throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
436
+ `--project`ではそのproject内で会話本文を持つ最新sessionを選び、存在しなければ`empty`を返す。
437
+ `--disclosure silent`は長期記憶を追加せずThroughlineの案内を消す。`context`はSessionStartと
438
+ 同じ予算付き継承文脈である。`--session`で使える任意の`--supplement-file <path>`には
430
439
  `throughline.handoff_supplement.v1`、源sessionと同じ`projectPath`、`title`と`content`からなる
431
- `sections`を指定する。補足は会話記憶と同じ9,500字枠へ入り、別projectの補足は拒否する。
440
+ `sections`を指定する。任意の`handoffDisclosure`は`visible`(既定)または`silent`を受け取る。
441
+ 補足は会話記憶と同じ9,500字枠へ入り、別projectの補足は拒否する。
432
442
  DB作成・migration・baton消費・session merge・
433
- latest session推測・`sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
443
+ `sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
434
444
  別harness portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
435
445
 
436
446
  スラッシュコマンド (Claude Code 内でユーザーが叩く):
package/README.md CHANGED
@@ -817,13 +817,21 @@ entry to the `tasks` array yourself:
817
817
 
818
818
  ## Commands
819
819
 
820
- **The current release is v0.10.12.** Throughline supports Claude Code, Codex,
820
+ **The current release is v0.10.14.** Throughline supports Claude Code, Codex,
821
821
  Grok, and Cursor as documented host adapters. The versioned, read-only
822
822
  `handoff-context` boundary opens only an existing database and leaves baton
823
823
  state, session ownership, and memory rows unchanged. Factory diagnostics,
824
824
  Observer, runtime-error, capture, and normal handoff behavior remain available
825
825
  under the same explicit-failure and local-only contracts.
826
826
 
827
+ Normal handoffs announce inherited continuity once in the first response and
828
+ explicitly forbid repeating that line in later responses. A project-bound
829
+ launcher may pass `--disclosure silent` for an embedded product that keeps
830
+ continuity metadata out of its chat. A supplement may also set
831
+ `handoffDisclosure` to `silent`. Legacy Throughline disclosure lines
832
+ in assistant memory are omitted from the next handoff; user quotations and the
833
+ actual reply body remain intact.
834
+
827
835
  | Command | What it does |
828
836
  | ---------------------------------------------- | ------------------------------------------------------------ |
829
837
  | `throughline install` | Register Claude user hooks/slash commands, the global Codex UserPromptSubmit/PostToolUse/Stop hooks, the global `$throughline` Codex skill, `~/.grok/hooks/throughline.json`, and the current VS Code monitor task when applicable |
@@ -855,7 +863,7 @@ once with `npm install --global throughline@latest`, then run
855
863
  | `throughline runtime-errors reopen <fingerprint> --json` | Explicitly reopen a resolved aggregate without fabricating a new occurrence |
856
864
  | `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
857
865
  | `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
858
- | `throughline handoff-context --session <id> --json [--supplement-file <path>]` | Print the SessionStart inheritance context without moving memory rows; an optional project-bound supplement adds long-term memory and knowledge inside the same 9,500-character budget |
866
+ | `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 |
859
867
  | `throughline latest-session --project <absolute-path> --json` | Read the latest session id strictly scoped to one project; opens the existing database read-only and returns `empty` when the project has no captured session |
860
868
  | `throughline grok-continue --session <id>` | Spawn a person-facing Grok seat whose first user text is the handoff-context body. cwd is the source session `project_path`. Does not spawn without ready context. No `--rules`. macOS Terminal only |
861
869
  | `throughline codex-capture --codex-thread-id <id>` | Capture active Codex rollout turns into a `codex:<thread_id>` DB session |
@@ -905,17 +913,22 @@ performing a normal handoff:
905
913
 
906
914
  ```bash
907
915
  throughline handoff-context --session codex:<thread-id> --json
916
+ throughline handoff-context --project /absolute/bot/project --json --disclosure silent
908
917
  ```
909
918
 
910
- The successful `throughline.handoff_context.v1` object contains only `schema`,
911
- `status`, `sessionId`, and `context`. The context is the same budgeted
912
- inheritance text used by SessionStart. A launcher may append
919
+ The `throughline.handoff_context.v1` object contains only `schema`, `status`,
920
+ `sessionId`, and `context`. With `--project`, Throughline selects the newest
921
+ session in that project that has dialogue context; it returns `status: "empty"`
922
+ when none exists. `--disclosure silent` removes the Throughline announcement
923
+ without adding long-term memory. The context uses the same budget as
924
+ SessionStart. With `--session`, a launcher may append
913
925
  `--supplement-file <path>` with a `throughline.handoff_supplement.v1` JSON object:
914
926
 
915
927
  ```json
916
928
  {
917
929
  "schema": "throughline.handoff_supplement.v1",
918
930
  "projectPath": "/absolute/bot/project",
931
+ "handoffDisclosure": "silent",
919
932
  "sections": [
920
933
  { "title": "Long-term memory", "content": "..." },
921
934
  { "title": "Relevant knowledge", "content": "..." }
@@ -923,11 +936,12 @@ inheritance text used by SessionStart. A launcher may append
923
936
  }
924
937
  ```
925
938
 
939
+ `handoffDisclosure` is optional and accepts `visible` (the default) or `silent`.
926
940
  The supplement is included only when `projectPath` matches the source session
927
941
  and shares the 9,500-character budget with conversation memory. If the captured
928
942
  session has no dialogue context yet, a valid project-bound supplement is returned
929
943
  by itself. The command does not create or migrate a
930
- database, consume a baton, merge sessions, infer a latest session, change
944
+ database, consume a baton, merge sessions, change
931
945
  `sessions.merged_into`, or reassign L1/L2/L3 rows. AIterm uses this boundary for
932
946
  its optional cross-harness portable fork; the Observer feed is a separate
933
947
  completed-turn projection and is not a substitute.
@@ -11,7 +11,7 @@
11
11
  * throughline detail <時刻> # L2+L3 詳細取得 (Claude が Bash 経由で呼ぶ想定)
12
12
  * throughline recall --l2|--l1 # 注入案内から辿る pull 用 read-only 記憶取得
13
13
  * throughline handoff-preview # Codex-facing throughline_handoff JSON preview
14
- * throughline handoff-context --session <id> --json [--supplement-file <path>] # Read-only inheritance context JSON
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> --json [--supplement-file <path>]
239
+ throughline handoff-context (--session <id> | --project <path>) --json
240
240
  Print the exact inheritance context without
241
- changing database ownership; optionally include
242
- project-bound long-term memory and knowledge
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
- - release candidateのpackage版は `0.10.12`。tagとnpmの公開確認はpublish後に行う。
9
+ - release candidateのpackage版は `0.10.14`。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の判断は
@@ -52,6 +52,11 @@ Grok 1.0.4 バイナリの hook 出力型は `GateHookJson`(PreToolUse deny/up
52
52
  この後ユーザーが指示を出す。何もせず待機すること。
53
53
  ```
54
54
 
55
+ - 通常の`{context}`は引き継ぎの事実と保持ターン数を最初の応答で一度だけ案内し、後続応答では
56
+ 繰り返させない。project束縛済み補足が`handoffDisclosure: silent`を指定した時だけ案内しない。
57
+ Throughline自身が旧版でassistantへ付けた固定宣言は後継へ渡す会話本文から除外し、ユーザーが
58
+ 同じ文を引用した内容は保持する。
59
+
55
60
  - spawn の cwd は源セッションの `project_path`。呼び出し元 cwd は使わない。共有 `GROK_HOME` の対話 `grok`。位置引数 `[PROMPT]` を使う。`--rules` / `--system-prompt-override` / `--agent` / 単発 `-p` は使わない。aiterm は使わない。
56
61
  - Claude / Codex の `/tl` 契約は変えない。Grok `/tl` 成功後の副作用起動だけが配線対象。
57
62
  - 一覧の正は `~/.grok/sessions/<encodeURIComponent(cwd)>/<id>/`。Desktop Inactive 畳みは成功条件にしない。
@@ -0,0 +1,37 @@
1
+ # ADR 0023: 引き継ぎ案内を通常は一度だけ表示する
2
+
3
+ Date: 2026-09-02
4
+
5
+ ## Status
6
+
7
+ Accepted
8
+
9
+ ## Context
10
+
11
+ v0.10.12以前の引き継ぎコンテキストは、モデルへ「Throughlineで前のセッションから
12
+ Nターン分の記憶を引き継いだ」と応答冒頭で宣言させていた。この命令は最初の応答と
13
+ ともに同じhost sessionへ残り、再起動が一度しか起きていなくても後続応答で同じ宣言を
14
+ 模倣させた。さらに、その応答を次の引き継ぎへ再投入すると宣言が連鎖する。
15
+
16
+ 一方、通常のThroughline利用では、引き継ぎが成立したことを最初の応答で確認できる案内は
17
+ 必要である。BellTeamのような埋込製品は会話画面へ基盤のメタ情報を出さない。
18
+
19
+ ## Decision
20
+
21
+ - 通常の引き継ぎは、引き継ぎの事実と保持ターン数を最初の応答で一度だけ案内する。
22
+ - 同じhost sessionの2回目以降の応答では案内を繰り返させない。
23
+ - `handoff-context`は`--disclosure visible|silent`を受け取り、`silent`を指定した埋込製品では
24
+ 補足ファイルを作らずに案内を消せる。
25
+ - project束縛済み`throughline.handoff_supplement.v1`も任意の`handoffDisclosure`として
26
+ `visible`(既定)または`silent`を持てる。
27
+ - Throughline自身が旧版でassistantへ付けた固定宣言行は、後継へ渡すassistant本文から除外する。
28
+ - ユーザー発言に含まれる同じ文字列は会話内容として保持する。
29
+ - 引き継ぎの成立はsession対応、生成コンテキスト、継続応答の内容で検証する。宣言の反復を
30
+ 成立判定に使わない。
31
+
32
+ ## Consequences
33
+
34
+ - 通常のThroughline利用では従来の引き継ぎ案内が残る。
35
+ - 埋込製品はproject分離境界と公開CLI引数を使って案内だけを非表示にできる。
36
+ - 旧宣言が保存済みのsessionも、次回の正規引き継ぎでメタ行を持ち越さない。
37
+ - 保存済みの原会話は監査記録として変更しない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.10.12",
3
+ "version": "0.10.14",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -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
- const baseArgsInvalid =
17
- (argv.length !== 3 && argv.length !== 5) ||
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
- const supplementArgsValid = argv.length === 3 || (
23
- argv[3] === '--supplement-file' &&
24
- typeof argv[4] === 'string' &&
25
- argv[4].length > 0
26
- );
27
- if (baseArgsInvalid || !supplementArgsValid) {
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
- supplementFile: argv.length === 5 ? argv[4] : null,
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
 
@@ -38,6 +65,8 @@ export function renderHandoffSupplement(value, projectPath) {
38
65
  value?.schema !== HANDOFF_SUPPLEMENT_SCHEMA ||
39
66
  !isAbsolute(value.projectPath) ||
40
67
  !sameProjectPath(value.projectPath, projectPath) ||
68
+ (value.handoffDisclosure !== undefined &&
69
+ !['visible', 'silent'].includes(value.handoffDisclosure)) ||
41
70
  !Array.isArray(value.sections) ||
42
71
  value.sections.length === 0
43
72
  ) {
@@ -59,27 +88,29 @@ export function renderHandoffSupplement(value, projectPath) {
59
88
  export function readHandoffContext(sessionId, {
60
89
  dbPath = join(homedir(), '.throughline', 'throughline.db'),
61
90
  supplementFile = null,
91
+ handoffDisclosure = 'visible',
62
92
  } = {}) {
63
93
  if (!existsSync(dbPath)) return null;
64
94
 
65
95
  const db = openReadOnlyDb(dbPath);
66
96
  try {
67
97
  let supplementContext = null;
98
+ let resolvedHandoffDisclosure = handoffDisclosure;
68
99
  if (supplementFile) {
69
100
  const row = db.prepare(
70
101
  'SELECT project_path FROM sessions WHERE session_id = ?',
71
102
  ).get(sessionId);
72
103
  if (!row?.project_path) return null;
73
- supplementContext = renderHandoffSupplement(
74
- JSON.parse(readFileSync(supplementFile, 'utf8')),
75
- row.project_path,
76
- );
104
+ const supplement = JSON.parse(readFileSync(supplementFile, 'utf8'));
105
+ supplementContext = renderHandoffSupplement(supplement, row.project_path);
106
+ resolvedHandoffDisclosure = supplement.handoffDisclosure ?? handoffDisclosure;
77
107
  }
78
108
 
79
109
  const separator = supplementContext ? '\n\n' : '';
80
110
  const context = buildBudgetedResumeContext(db, {
81
111
  sessionId,
82
112
  isInheritance: true,
113
+ handoffDisclosure: resolvedHandoffDisclosure,
83
114
  maxChars: INJECTION_BUDGET_CHARS - (supplementContext?.length ?? 0) - separator.length,
84
115
  })?.text ?? null;
85
116
  if (!context && !supplementContext) return null;
@@ -96,6 +127,34 @@ export function readHandoffContext(sessionId, {
96
127
  }
97
128
  }
98
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
+
99
158
  export function readSessionProjectPath(sessionId, {
100
159
  dbPath = join(homedir(), '.throughline', 'throughline.db'),
101
160
  } = {}) {
@@ -119,26 +178,43 @@ export function run(argv = [], {
119
178
  stdout = process.stdout,
120
179
  stderr = process.stderr,
121
180
  readContext = readHandoffContext,
181
+ readProjectContext = readLatestProjectHandoffContext,
122
182
  } = {}) {
123
183
  let sessionId;
184
+ let projectPath;
124
185
  let supplementFile;
186
+ let handoffDisclosure;
125
187
  try {
126
- ({ sessionId, supplementFile } = parseArgs(argv));
188
+ ({ sessionId, projectPath, supplementFile, handoffDisclosure } = parseArgs(argv));
127
189
  } catch {
128
190
  stderr.write(
129
- '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',
130
192
  );
131
193
  return 2;
132
194
  }
133
195
 
134
- let context;
196
+ let result;
135
197
  try {
136
- context = readContext(sessionId, { supplementFile });
198
+ result = projectPath
199
+ ? readProjectContext(projectPath, { handoffDisclosure })
200
+ : {
201
+ sessionId,
202
+ context: readContext(sessionId, { supplementFile, handoffDisclosure }),
203
+ };
137
204
  } catch {
138
205
  stderr.write('Throughline handoff context could not be read.\n');
139
206
  return 1;
140
207
  }
141
- 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) {
142
218
  stderr.write('Throughline handoff context is not available for that session.\n');
143
219
  return 1;
144
220
  }
@@ -146,8 +222,8 @@ export function run(argv = [], {
146
222
  stdout.write(`${JSON.stringify({
147
223
  schema: HANDOFF_CONTEXT_SCHEMA,
148
224
  status: 'ready',
149
- sessionId,
150
- context,
225
+ sessionId: result.sessionId,
226
+ context: result.context,
151
227
  })}\n`);
152
228
  return 0;
153
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 { parseArgs, readSessionProjectPath } from './handoff-context.mjs';
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');
@@ -169,6 +173,125 @@ test('handoff-context adds a project-bound supplement inside the shared budget',
169
173
  }
170
174
  });
171
175
 
176
+ test('handoff-context supplement can silence disclosure without changing normal default', () => {
177
+ const home = mkdtempSync(join(tmpdir(), 'tl-handoff-silent-'));
178
+ try {
179
+ const { db } = createFixture(home);
180
+ db.close();
181
+ const supplementFile = join(home, 'supplement.json');
182
+ writeFileSync(supplementFile, JSON.stringify({
183
+ schema: 'throughline.handoff_supplement.v1',
184
+ projectPath: '/work/project',
185
+ handoffDisclosure: 'silent',
186
+ sections: [{ title: 'Botプロフィール', content: 'BellTeamのBot' }],
187
+ }));
188
+
189
+ const normal = runCli(home);
190
+ assert.equal(normal.status, 0, normal.stderr);
191
+ assert.match(JSON.parse(normal.stdout).context, /この引き継ぎ直後の最初の応答だけ/);
192
+
193
+ const silent = runCli(home, [
194
+ 'handoff-context', '--session', SESSION_ID, '--json',
195
+ '--supplement-file', supplementFile,
196
+ ]);
197
+ assert.equal(silent.status, 0, silent.stderr);
198
+ const context = JSON.parse(silent.stdout).context;
199
+ assert.doesNotMatch(context, /この引き継ぎ直後の最初の応答だけ/);
200
+ assert.doesNotMatch(context, /Throughline で前のセッションから .* ターン分/);
201
+ assert.match(context, /BellTeamのBot/);
202
+ assert.match(context, /所有権を変えずに記憶を渡して/);
203
+ } finally {
204
+ rmSync(home, { recursive: true, force: true });
205
+ }
206
+ });
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
+
172
295
  test('handoff-context returns a project-bound supplement when the captured session has no dialogue yet', () => {
173
296
  const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-only-'));
174
297
  try {
@@ -229,15 +352,33 @@ test('handoff-context refuses a supplement from another bot project', () => {
229
352
  test('handoff-context accepts only the documented supplement argument shape', () => {
230
353
  assert.deepEqual(parseArgs(['--session', 's', '--json']), {
231
354
  sessionId: 's',
355
+ projectPath: null,
232
356
  supplementFile: null,
357
+ handoffDisclosure: 'visible',
233
358
  });
234
359
  assert.deepEqual(parseArgs([
235
360
  '--session', 's', '--json', '--supplement-file', '/tmp/memory.json',
236
361
  ]), {
237
362
  sessionId: 's',
363
+ projectPath: null,
238
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',
239
374
  });
240
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
+ ]));
241
382
  });
242
383
 
243
384
  test('readSessionProjectPath returns the source session project', () => {
@@ -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/);
@@ -24,11 +24,14 @@
24
24
  import { buildHandoffRecord } from './handoff-record.mjs';
25
25
  import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
26
26
 
27
- const RESUME_HEADER_TEMPLATE = (turnCount) =>
27
+ const RESUME_HEADER_TEMPLATE = (turnCount, handoffDisclosure) =>
28
28
  `## Throughline: 直前スレッドの継続応答用コンテキスト(${turnCount} ターン分の文脈を保持)\n` +
29
- `\n` +
30
- `**応答の冒頭で必ず以下を 1 行宣言してください** (会話の継続性をユーザーに見せるため):\n` +
31
- `> 「Throughline で前のセッションから ${turnCount} ターン分の記憶を引き継いだ状態で続けます」\n` +
29
+ (handoffDisclosure === 'visible'
30
+ ? `\n` +
31
+ `**この引き継ぎ直後の最初の応答だけ、冒頭で以下を 1 行宣言してください**:\n` +
32
+ `> 「Throughline で前のセッションから ${turnCount} ターン分の記憶を引き継いだ状態で続けます」\n` +
33
+ `**2 回目以降の応答では、この宣言を繰り返さないでください。**\n`
34
+ : '') +
32
35
  `\n` +
33
36
  `**読み方 (重要):**\n` +
34
37
  `- 下記「現在地」「L1」「L2」は **あなた自身が直前にユーザーと交わした会話** です ` +
@@ -52,6 +55,17 @@ const RESUME_HEADER_TEMPLATE = (turnCount) =>
52
55
 
53
56
  const NORMAL_HEADER = '## Throughline: セッション記憶';
54
57
 
58
+ // v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
59
+ // 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
60
+ // userが同じ文を引用した場合は会話内容なので保持する。
61
+ const LEGACY_HANDOFF_DISCLOSURE =
62
+ /^「Throughline で前のセッションから \d+ ターン分の記憶を引き継いだ状態で続けます」[ \t]*(?:\r?\n[ \t]*)*/u;
63
+
64
+ function textForHandoff(row) {
65
+ if (row.role !== 'assistant') return row.text;
66
+ return row.text.replace(LEGACY_HANDOFF_DISCLOSURE, '');
67
+ }
68
+
55
69
  // 現在地アンカーは最新 user / assistant 本文を再掲する。長すぎると注入全体が膨らむので
56
70
  // この文字数で打ち切り、全文は L2 セクション側を参照させる。
57
71
  const ANCHOR_MAX_CHARS = 600;
@@ -93,7 +107,13 @@ function pickLatestExchange(recentBodies) {
93
107
  * l2Lines: { text: string, time: string }[],
94
108
  * } | null}
95
109
  */
96
- function buildResumeSections(db, { sessionId, isInheritance, excludeOriginId = null }) {
110
+ function buildResumeSections(
111
+ db,
112
+ { sessionId, isInheritance, excludeOriginId = null, handoffDisclosure = 'visible' },
113
+ ) {
114
+ if (!['visible', 'silent'].includes(handoffDisclosure)) {
115
+ throw new TypeError('invalid handoff disclosure');
116
+ }
97
117
  const record = buildHandoffRecord(db, {
98
118
  sessionId,
99
119
  isInheritance,
@@ -102,15 +122,21 @@ function buildResumeSections(db, { sessionId, isInheritance, excludeOriginId = n
102
122
  if (!record) return null;
103
123
 
104
124
  const turnCount = record.stats.preservedContextRows;
105
- const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
125
+ const header = isInheritance
126
+ ? RESUME_HEADER_TEMPLATE(turnCount, handoffDisclosure)
127
+ : NORMAL_HEADER;
128
+ const recentBodies = record.memory.recentBodies.map((row) => ({
129
+ ...row,
130
+ text: textForHandoff(row),
131
+ }));
106
132
 
107
133
  const l3ByTurn = groupL3ByTurn(record.references.l3);
108
134
 
109
135
  // 現在地アンカー: 引き継ぎ時のみ、最新 user / assistant turn をヘッダ直下に再掲する。
110
136
  // L2 末尾アンカーだけだと、長い L2 で注意が前半に固着して話の流れを取り違える事例があった。
111
137
  const anchorLines = [];
112
- if (isInheritance && record.memory.recentBodies.length > 0) {
113
- const { latestUser, latestAssistant } = pickLatestExchange(record.memory.recentBodies);
138
+ if (isInheritance && recentBodies.length > 0) {
139
+ const { latestUser, latestAssistant } = pickLatestExchange(recentBodies);
114
140
  if (latestUser) {
115
141
  anchorLines.push(
116
142
  `**最新ユーザー指示** [${latestUser.time}]: ${truncateForAnchor(latestUser.text)}`,
@@ -145,14 +171,14 @@ function buildResumeSections(db, { sessionId, isInheritance, excludeOriginId = n
145
171
  // 同じターンの user 行と assistant 行の両方に貼ると同じ内容が二度出て紛らわしい。
146
172
  const l2Lines = [];
147
173
  const lastIdxPerTurn = new Map();
148
- for (let i = 0; i < record.memory.recentBodies.length; i += 1) {
149
- const r = record.memory.recentBodies[i];
174
+ for (let i = 0; i < recentBodies.length; i += 1) {
175
+ const r = recentBodies[i];
150
176
  if (!r.text) continue;
151
177
  const key = `${r.originSessionId}\x00${r.turnNumber}`;
152
178
  lastIdxPerTurn.set(key, i);
153
179
  }
154
- for (let i = 0; i < record.memory.recentBodies.length; i += 1) {
155
- const r = record.memory.recentBodies[i];
180
+ for (let i = 0; i < recentBodies.length; i += 1) {
181
+ const r = recentBodies[i];
156
182
  if (!r.text) continue;
157
183
  const key = `${r.originSessionId}\x00${r.turnNumber}`;
158
184
  const isLastOfTurn = lastIdxPerTurn.get(key) === i;
@@ -207,9 +233,20 @@ function joinSections({ header, anchorLines, l1Lines, l2Lines }, { l1Note = null
207
233
  */
208
234
  export function buildResumeContext(
209
235
  db,
210
- { sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
236
+ {
237
+ sessionId,
238
+ isInheritance,
239
+ excludeOriginId = null,
240
+ inflightMemo: _ignoredMemo = null,
241
+ handoffDisclosure = 'visible',
242
+ },
211
243
  ) {
212
- const sections = buildResumeSections(db, { sessionId, isInheritance, excludeOriginId });
244
+ const sections = buildResumeSections(db, {
245
+ sessionId,
246
+ isInheritance,
247
+ excludeOriginId,
248
+ handoffDisclosure,
249
+ });
213
250
  if (!sections) return null;
214
251
  return joinSections(sections);
215
252
  }
@@ -344,9 +381,20 @@ function buildGuidanceLines({ sessionId, boundaryMs, remainingTurns, remainingRa
344
381
  */
345
382
  export function buildBudgetedResumeContext(
346
383
  db,
347
- { sessionId, isInheritance, excludeOriginId = null, maxChars = INJECTION_BUDGET_CHARS },
384
+ {
385
+ sessionId,
386
+ isInheritance,
387
+ excludeOriginId = null,
388
+ maxChars = INJECTION_BUDGET_CHARS,
389
+ handoffDisclosure = 'visible',
390
+ },
348
391
  ) {
349
- const sections = buildResumeSections(db, { sessionId, isInheritance, excludeOriginId });
392
+ const sections = buildResumeSections(db, {
393
+ sessionId,
394
+ isInheritance,
395
+ excludeOriginId,
396
+ handoffDisclosure,
397
+ });
350
398
  if (!sections) return null;
351
399
 
352
400
  const lineCost = (s) => s.length + 1; // join('\n') 分
@@ -113,10 +113,10 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
113
113
  assert.match(text, /新規依頼ではなく、上記スレッドの \*\*続き\*\*/);
114
114
  assert.match(text, /続きよろしく.*OK.*次は?/s);
115
115
  assert.match(text, /新規会話ではない/);
116
- // β 経路 (early-style explicit report-back instruction): モデルが冒頭で
117
- // 「引き継いだ状態で続けます」と明示的に表明することで、user が体感する継続感を強める
118
- assert.match(text, /応答の冒頭で必ず以下を 1 行宣言/);
116
+ // 通常のThroughline引き継ぎは最初の一度だけ可視化し、後続応答では反復させない。
117
+ assert.match(text, /この引き継ぎ直後の最初の応答だけ/);
119
118
  assert.match(text, /Throughline で前のセッションから .* ターン分の記憶を引き継いだ状態で続けます/);
119
+ assert.match(text, /2 回目以降の応答では、この宣言を繰り返さない/);
120
120
  assert.match(
121
121
  text,
122
122
  /\*\*各ターンの詳細\*\*: \*\*`Bash` ツールで `throughline detail HH:MM:SS` を実行\*\* \(該当ターンの本文+詳細を stdout に返します\)/,
@@ -130,6 +130,61 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
130
130
  assert.match(text, /最新アシスタント発話の指示が、過去ターンのリストへの参照より上位/);
131
131
  });
132
132
 
133
+ test('buildResumeContext: silent disclosure keeps inheritance internal', () => {
134
+ const db = makeDb();
135
+ insertBody(db, {
136
+ session: 'new',
137
+ origin: 'old',
138
+ turn: 1,
139
+ role: 'assistant',
140
+ text: '続きの本文',
141
+ createdAt: 1000,
142
+ });
143
+
144
+ const text = buildResumeContext(db, {
145
+ sessionId: 'new',
146
+ isInheritance: true,
147
+ handoffDisclosure: 'silent',
148
+ });
149
+
150
+ assert.ok(text);
151
+ assert.doesNotMatch(text, /この引き継ぎ直後の最初の応答だけ/);
152
+ assert.doesNotMatch(text, /Throughline で前のセッションから .* ターン分の記憶を引き継いだ状態で続けます/);
153
+ assert.match(text, /\[assistant\]: 続きの本文/);
154
+ });
155
+
156
+ test('buildResumeContext: legacy disclosure is removed only from assistant memory', () => {
157
+ const db = makeDb();
158
+ const disclosure = '「Throughline で前のセッションから 20 ターン分の記憶を引き継いだ状態で続けます」';
159
+ insertBody(db, {
160
+ session: 'new',
161
+ origin: 'old',
162
+ turn: 1,
163
+ role: 'user',
164
+ text: `この表示は何? ${disclosure}`,
165
+ createdAt: 1000,
166
+ });
167
+ insertBody(db, {
168
+ session: 'new',
169
+ origin: 'old',
170
+ turn: 1,
171
+ role: 'assistant',
172
+ text: `${disclosure}\n\n本題の応答です。`,
173
+ createdAt: 1001,
174
+ });
175
+
176
+ const text = buildResumeContext(db, {
177
+ sessionId: 'new',
178
+ isInheritance: true,
179
+ });
180
+
181
+ assert.ok(text);
182
+ assert.equal(text.split(disclosure).length - 1, 2, 'user quote remains in anchor and L2 only');
183
+ assert.doesNotMatch(text, /\[assistant\]: 「Throughline で前のセッションから/);
184
+ assert.match(text, /\[assistant\]: 本題の応答です。/);
185
+ assert.match(text, /\*\*直前のアシスタント\*\* \[\d\d:\d\d:\d\d\]: 本題の応答です。/);
186
+ });
187
+
133
188
  test('buildResumeContext: 現在地 anchor surfaces the latest user/assistant exchange above L1/L2', () => {
134
189
  const db = makeDb();
135
190
  // 25 turns to exercise an L2 window edge and ensure the anchor picks the newest.