throughline 0.10.6 → 0.10.8

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,23 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.8] — 2026-09-01
14
+
15
+ ### Added
16
+
17
+ - `handoff-context`へ任意の`--supplement-file`を追加した。補足JSONは元sessionの
18
+ `project_path`と一致する場合だけ、長期記憶・RAGとして既存の9,500字枠へ合成する。
19
+ DBの所有権と既存の補足なし出力は変えない。
20
+
21
+ ## [0.10.7] — 2026-09-01
22
+
23
+ ### Added
24
+
25
+ - `throughline latest-session --project <absolute-path> --json`で、指定した
26
+ projectだけの直近session IDをread-onlyで取得できるようにした。
27
+ BellTeamのような外部ランチャーは、別projectの記憶を混ぜずに既存の
28
+ `handoff-context`境界へ接続できる。
29
+
13
30
  ## [0.10.6] — 2026-09-01
14
31
 
15
32
  ### Fixed
@@ -1370,7 +1387,9 @@ two attempts, instrument first instead of patching again.
1370
1387
 
1371
1388
  ---
1372
1389
 
1373
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.6...HEAD
1390
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.8...HEAD
1391
+ [0.10.8]: https://github.com/kitepon/Throughline/compare/v0.10.7...v0.10.8
1392
+ [0.10.7]: https://github.com/kitepon/Throughline/compare/v0.10.6...v0.10.7
1374
1393
  [0.10.6]: https://github.com/kitepon/Throughline/compare/v0.10.5...v0.10.6
1375
1394
  [0.10.5]: https://github.com/kitepon/Throughline/compare/v0.10.4...v0.10.5
1376
1395
  [0.10.4]: https://github.com/kitepon/Throughline/compare/v0.10.3...v0.10.4
package/README.ja.md CHANGED
@@ -390,7 +390,8 @@ v0.10.4以前には `self-update` が存在しない。該当版からの初回
390
390
  | `throughline runtime-errors snapshot --json` | boundedなlocal aggregateを読み取る(network I/Oなし) |
391
391
  | `throughline runtime-errors diagnostics --json` | collection/store状態をpathやraw errorなしで診断 |
392
392
  | `throughline handoff-preview --session <id>` | Codex 向け `throughline_handoff` JSON projection を表示 |
393
- | `throughline handoff-context --session <id> --json` | SessionStart と同じ引き継ぎ文脈を versioned JSON で取得。記憶行の `session_id` と `sessions.merged_into` は変更せず、同一端末内の別ベンダーランチャーから使える |
393
+ | `throughline handoff-context --session <id> --json [--supplement-file <path>]` | SessionStartと同じ引き継ぎ文脈を取得。任意のproject束縛済み補足を、長期記憶・知識として同じ9,500字枠へ合成できる |
394
+ | `throughline latest-session --project <absolute-path> --json` | 指定した1プロジェクトだけを対象に直近セッションIDを読み取る。既存DBをread-onlyで開き、記録がなければ`empty`を返す |
394
395
  | `throughline grok-continue --session <id>` | handoff-context を初手 user 文にした対話 Grok 席を立てる。cwd は源の `project_path`。ready でなければ spawn しない。`--rules` なし。macOS Terminal のみ |
395
396
  | `throughline codex-sidecar-diagnostics` | この project の `codex-sidecar` diagnostics status を確認 |
396
397
  | `throughline codex-sidecar-dry-run` | App Server を呼ばずに read-only sidecar request を正規化表示 |
@@ -425,7 +426,10 @@ throughline handoff-context --session codex:<thread-id> --json
425
426
  ```
426
427
 
427
428
  成功時の`throughline.handoff_context.v1`は`schema`、`status`、`sessionId`、`context`だけを返す。
428
- `context`はSessionStartと同じ予算付き継承文脈で、DB作成・migration・baton消費・session merge・
429
+ `context`はSessionStartと同じ予算付き継承文脈である。任意の`--supplement-file <path>`には
430
+ `throughline.handoff_supplement.v1`、源sessionと同じ`projectPath`、`title`と`content`からなる
431
+ `sections`を指定する。補足は会話記憶と同じ9,500字枠へ入り、別projectの補足は拒否する。
432
+ DB作成・migration・baton消費・session merge・
429
433
  latest session推測・`sessions.merged_into`変更・L1/L2/L3 rowの所属変更は行わない。AItermは任意の
430
434
  別harness portable forkでこの境界を使う。Observer feedはcompleted-turn projectionであり代替ではない。
431
435
 
package/README.md CHANGED
@@ -817,7 +817,7 @@ entry to the `tasks` array yourself:
817
817
 
818
818
  ## Commands
819
819
 
820
- **The current release is v0.10.6.** Throughline supports Claude Code, Codex,
820
+ **The current release is v0.10.8.** Throughline supports Claude Code, Codex,
821
821
  Grok, and Cursor as documented host adapters. The versioned, read-only
822
822
  `handoff-context` boundary opens only an existing database and leaves baton
823
823
  state, session ownership, and memory rows unchanged. Factory diagnostics,
@@ -855,7 +855,8 @@ once with `npm install --global throughline@latest`, then run
855
855
  | `throughline runtime-errors reopen <fingerprint> --json` | Explicitly reopen a resolved aggregate without fabricating a new occurrence |
856
856
  | `throughline runtime-errors compact --json` | Remove only acknowledged, resolved aggregates after retention; open or unacknowledged records remain |
857
857
  | `throughline handoff-preview --session <id>` | Print a Codex-facing `throughline_handoff` JSON projection |
858
- | `throughline handoff-context --session <id> --json` | Print the 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 |
858
+ | `throughline handoff-context --session <id> --json [--supplement-file <path>]` | Print the SessionStart inheritance context without moving memory rows; an optional project-bound supplement adds long-term memory and knowledge inside the same 9,500-character budget |
859
+ | `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 |
859
860
  | `throughline grok-continue --session <id>` | Spawn a person-facing Grok seat whose first user text is the handoff-context body. cwd is the source session `project_path`. Does not spawn without ready context. No `--rules`. macOS Terminal only |
860
861
  | `throughline codex-capture --codex-thread-id <id>` | Capture active Codex rollout turns into a `codex:<thread_id>` DB session |
861
862
  | `throughline codex-summarize --session codex:<id>` | Summarize captured Codex L2 into L1 with the Codex CLI backend |
@@ -908,7 +909,22 @@ throughline handoff-context --session codex:<thread-id> --json
908
909
 
909
910
  The successful `throughline.handoff_context.v1` object contains only `schema`,
910
911
  `status`, `sessionId`, and `context`. The context is the same budgeted
911
- inheritance text used by SessionStart. The command does not create or migrate a
912
+ inheritance text used by SessionStart. A launcher may append
913
+ `--supplement-file <path>` with a `throughline.handoff_supplement.v1` JSON object:
914
+
915
+ ```json
916
+ {
917
+ "schema": "throughline.handoff_supplement.v1",
918
+ "projectPath": "/absolute/bot/project",
919
+ "sections": [
920
+ { "title": "Long-term memory", "content": "..." },
921
+ { "title": "Relevant knowledge", "content": "..." }
922
+ ]
923
+ }
924
+ ```
925
+
926
+ The supplement is included only when `projectPath` matches the source session
927
+ and shares the 9,500-character budget with conversation memory. The command does not create or migrate a
912
928
  database, consume a baton, merge sessions, infer a latest session, change
913
929
  `sessions.merged_into`, or reassign L1/L2/L3 rows. AIterm uses this boundary for
914
930
  its optional cross-harness portable fork; the Observer feed is a separate
@@ -11,7 +11,8 @@
11
11
  * throughline detail <時刻> # L2+L3 詳細取得 (Claude が Bash 経由で呼ぶ想定)
12
12
  * throughline recall --l2|--l1 # 注入案内から辿る pull 用 read-only 記憶取得
13
13
  * throughline handoff-preview # Codex-facing throughline_handoff JSON preview
14
- * throughline handoff-context --session <id> --json # Read-only inheritance context JSON
14
+ * throughline handoff-context --session <id> --json [--supplement-file <path>] # Read-only inheritance context JSON
15
+ * throughline latest-session --project <path> --json # Latest session strictly scoped to one project
15
16
  * throughline grok-continue --session <id> # Spawn a Grok seat whose first user text is handoff-context
16
17
  * throughline auditor-context --json # Read-only bounded auditor context JSON
17
18
  * throughline factory-diagnostics --json # Native factory read-only readiness JSON
@@ -85,6 +86,11 @@ switch (cmd) {
85
86
  if (exitCode !== 0) process.exitCode = exitCode;
86
87
  break;
87
88
  }
89
+ case 'latest-session': {
90
+ const exitCode = (await import('../src/cli/latest-session.mjs')).run(rest);
91
+ if (exitCode !== 0) process.exitCode = exitCode;
92
+ break;
93
+ }
88
94
  case 'grok-continue': {
89
95
  const exitCode = (await import('../src/cli/grok-continue.mjs')).run(rest);
90
96
  if (exitCode !== 0) process.exitCode = exitCode;
@@ -230,9 +236,12 @@ Usage:
230
236
  throughline monitor Multi-session token monitor (use --all, --session <id>)
231
237
  throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
232
238
  throughline handoff-preview Print Codex-facing throughline_handoff JSON
233
- throughline handoff-context --session <id> --json
239
+ throughline handoff-context --session <id> --json [--supplement-file <path>]
234
240
  Print the exact inheritance context without
235
- changing database ownership
241
+ changing database ownership; optionally include
242
+ project-bound long-term memory and knowledge
243
+ throughline latest-session --project <absolute-path> --json
244
+ Read the latest session id for exactly one project
236
245
  throughline grok-continue --session <id>
237
246
  Spawn a person-facing Grok seat whose first
238
247
  user text is the handoff-context body. Does not
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 現行状態
8
8
 
9
- - release candidateのpackage版は `0.10.6`。tagとnpmの公開確認はpublish後に行う。
9
+ - release candidateのpackage版は `0.10.8`。tagとnpmの公開確認はpublish後に行う。
10
10
  - Claude Code、Codex、Grok、Cursorをfirst-class hostとして扱う。
11
11
  - 現行DB schemaはv9。schemaの正本は [src/db.mjs](../src/db.mjs) の
12
12
  `CURRENT_VERSION`、二相handoffの判断は
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
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,36 +1,96 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { isAbsolute, join } from 'node:path';
5
5
 
6
- import { buildBudgetedResumeContext } from '../resume-context.mjs';
6
+ import { sameProjectPath } from '../project-path.mjs';
7
+ import {
8
+ buildBudgetedResumeContext,
9
+ INJECTION_BUDGET_CHARS,
10
+ } from '../resume-context.mjs';
7
11
 
8
12
  export const HANDOFF_CONTEXT_SCHEMA = 'throughline.handoff_context.v1';
13
+ export const HANDOFF_SUPPLEMENT_SCHEMA = 'throughline.handoff_supplement.v1';
9
14
 
10
15
  export function parseArgs(argv = []) {
11
- if (
12
- argv.length !== 3 ||
16
+ const baseArgsInvalid =
17
+ (argv.length !== 3 && argv.length !== 5) ||
13
18
  argv[0] !== '--session' ||
14
19
  typeof argv[1] !== 'string' ||
15
20
  argv[1].length === 0 ||
16
- argv[2] !== '--json'
17
- ) {
21
+ argv[2] !== '--json';
22
+ const supplementArgsValid = argv.length === 3 || (
23
+ argv[3] === '--supplement-file' &&
24
+ typeof argv[4] === 'string' &&
25
+ argv[4].length > 0
26
+ );
27
+ if (baseArgsInvalid || !supplementArgsValid) {
18
28
  throw new TypeError('usage error');
19
29
  }
20
- return { sessionId: argv[1] };
30
+ return {
31
+ sessionId: argv[1],
32
+ supplementFile: argv.length === 5 ? argv[4] : null,
33
+ };
34
+ }
35
+
36
+ export function renderHandoffSupplement(value, projectPath) {
37
+ if (
38
+ value?.schema !== HANDOFF_SUPPLEMENT_SCHEMA ||
39
+ !isAbsolute(value.projectPath) ||
40
+ !sameProjectPath(value.projectPath, projectPath) ||
41
+ !Array.isArray(value.sections) ||
42
+ value.sections.length === 0
43
+ ) {
44
+ throw new TypeError('invalid handoff supplement');
45
+ }
46
+
47
+ const sections = value.sections.map((section) => {
48
+ if (
49
+ typeof section?.title !== 'string' || section.title.length === 0 ||
50
+ typeof section?.content !== 'string' || section.content.length === 0
51
+ ) {
52
+ throw new TypeError('invalid handoff supplement section');
53
+ }
54
+ return `### ${section.title}\n${section.content}`;
55
+ });
56
+ return ['## このBotの長期記憶と関連知識', ...sections].join('\n\n');
21
57
  }
22
58
 
23
59
  export function readHandoffContext(sessionId, {
24
60
  dbPath = join(homedir(), '.throughline', 'throughline.db'),
61
+ supplementFile = null,
25
62
  } = {}) {
26
63
  if (!existsSync(dbPath)) return null;
27
64
 
28
65
  const db = new DatabaseSync(dbPath, { readOnly: true });
29
66
  try {
30
- return buildBudgetedResumeContext(db, {
67
+ let supplementContext = null;
68
+ if (supplementFile) {
69
+ const row = db.prepare(
70
+ 'SELECT project_path FROM sessions WHERE session_id = ?',
71
+ ).get(sessionId);
72
+ if (!row?.project_path) return null;
73
+ supplementContext = renderHandoffSupplement(
74
+ JSON.parse(readFileSync(supplementFile, 'utf8')),
75
+ row.project_path,
76
+ );
77
+ }
78
+
79
+ const separator = supplementContext ? '\n\n' : '';
80
+ const context = buildBudgetedResumeContext(db, {
31
81
  sessionId,
32
82
  isInheritance: true,
83
+ maxChars: INJECTION_BUDGET_CHARS - (supplementContext?.length ?? 0) - separator.length,
33
84
  })?.text ?? null;
85
+ if (!context) return null;
86
+
87
+ const combined = supplementContext
88
+ ? `${supplementContext}${separator}${context}`
89
+ : context;
90
+ if (combined.length > INJECTION_BUDGET_CHARS) {
91
+ throw new RangeError('handoff context exceeds injection budget');
92
+ }
93
+ return combined;
34
94
  } finally {
35
95
  db.close();
36
96
  }
@@ -61,16 +121,19 @@ export function run(argv = [], {
61
121
  readContext = readHandoffContext,
62
122
  } = {}) {
63
123
  let sessionId;
124
+ let supplementFile;
64
125
  try {
65
- ({ sessionId } = parseArgs(argv));
126
+ ({ sessionId, supplementFile } = parseArgs(argv));
66
127
  } catch {
67
- stderr.write('Usage: throughline handoff-context --session <id> --json\n');
128
+ stderr.write(
129
+ 'Usage: throughline handoff-context --session <id> --json [--supplement-file <path>]\n',
130
+ );
68
131
  return 2;
69
132
  }
70
133
 
71
134
  let context;
72
135
  try {
73
- context = readContext(sessionId);
136
+ context = readContext(sessionId, { supplementFile });
74
137
  } catch {
75
138
  stderr.write('Throughline handoff context could not be read.\n');
76
139
  return 1;
@@ -1,14 +1,14 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { DatabaseSync } from 'node:sqlite';
9
9
 
10
- import { buildBudgetedResumeContext } from '../resume-context.mjs';
11
- import { readSessionProjectPath } from './handoff-context.mjs';
10
+ import { buildBudgetedResumeContext, INJECTION_BUDGET_CHARS } from '../resume-context.mjs';
11
+ import { parseArgs, readSessionProjectPath } from './handoff-context.mjs';
12
12
 
13
13
  const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
14
14
  const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
@@ -132,6 +132,80 @@ test('handoff-context emits the exact inheritance context without changing DB ow
132
132
  }
133
133
  });
134
134
 
135
+ test('handoff-context adds a project-bound supplement inside the shared budget', () => {
136
+ const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-'));
137
+ try {
138
+ const { db, dbPath } = createFixture(home);
139
+ const before = ownershipSnapshot(db);
140
+ db.close();
141
+ const supplementFile = join(home, 'supplement.json');
142
+ writeFileSync(supplementFile, JSON.stringify({
143
+ schema: 'throughline.handoff_supplement.v1',
144
+ projectPath: '/work/project',
145
+ sections: [
146
+ { title: '長期記憶', content: 'オーナーとの約束を大切にしている' },
147
+ { title: '関連知識', content: 'BellTeamではBotごとにprojectを分離する' },
148
+ ],
149
+ }));
150
+
151
+ const result = runCli(home, [
152
+ 'handoff-context', '--session', SESSION_ID, '--json',
153
+ '--supplement-file', supplementFile,
154
+ ]);
155
+ assert.equal(result.status, 0, result.stderr);
156
+ const context = JSON.parse(result.stdout).context;
157
+ assert.match(context, /このBotの長期記憶と関連知識/);
158
+ assert.match(context, /オーナーとの約束を大切にしている/);
159
+ assert.match(context, /BellTeamではBotごとにprojectを分離する/);
160
+ assert.match(context, /所有権を変えずに記憶を渡して/);
161
+ assert.ok(context.indexOf('長期記憶') < context.indexOf('直前の対話'));
162
+ assert.ok(context.length <= INJECTION_BUDGET_CHARS);
163
+
164
+ const verify = new DatabaseSync(dbPath, { readOnly: true });
165
+ assert.deepEqual(ownershipSnapshot(verify), before);
166
+ verify.close();
167
+ } finally {
168
+ rmSync(home, { recursive: true, force: true });
169
+ }
170
+ });
171
+
172
+ test('handoff-context refuses a supplement from another bot project', () => {
173
+ const home = mkdtempSync(join(tmpdir(), 'tl-handoff-supplement-scope-'));
174
+ try {
175
+ const { db } = createFixture(home);
176
+ db.close();
177
+ const supplementFile = join(home, 'supplement.json');
178
+ writeFileSync(supplementFile, JSON.stringify({
179
+ schema: 'throughline.handoff_supplement.v1',
180
+ projectPath: '/work/other-bot',
181
+ sections: [{ title: '長期記憶', content: 'B_PRIVATE_MEMORY' }],
182
+ }));
183
+
184
+ const result = runCli(home, [
185
+ 'handoff-context', '--session', SESSION_ID, '--json',
186
+ '--supplement-file', supplementFile,
187
+ ]);
188
+ assert.equal(result.status, 1);
189
+ assert.doesNotMatch(result.stdout, /B_PRIVATE_MEMORY/);
190
+ } finally {
191
+ rmSync(home, { recursive: true, force: true });
192
+ }
193
+ });
194
+
195
+ test('handoff-context accepts only the documented supplement argument shape', () => {
196
+ assert.deepEqual(parseArgs(['--session', 's', '--json']), {
197
+ sessionId: 's',
198
+ supplementFile: null,
199
+ });
200
+ assert.deepEqual(parseArgs([
201
+ '--session', 's', '--json', '--supplement-file', '/tmp/memory.json',
202
+ ]), {
203
+ sessionId: 's',
204
+ supplementFile: '/tmp/memory.json',
205
+ });
206
+ assert.throws(() => parseArgs(['--session', 's', '--supplement-file', '/tmp/memory.json', '--json']));
207
+ });
208
+
135
209
  test('readSessionProjectPath returns the source session project', () => {
136
210
  const home = mkdtempSync(join(tmpdir(), 'tl-session-project-'));
137
211
  try {
@@ -52,6 +52,7 @@ test('CLI help exposes guided Codex handoff and guarded execute semantics', () =
52
52
  assert.match(result.stdout, /--open-host auto\|desktop\|vscode\|cli\|none/);
53
53
  assert.match(result.stdout, /throughline trim --execute/);
54
54
  assert.match(result.stdout, /throughline handoff-context --session <id> --json/);
55
+ assert.match(result.stdout, /throughline latest-session --project <absolute-path> --json/);
55
56
  assert.match(result.stdout, /without\s+changing database ownership/);
56
57
  assert.match(result.stdout, /injectable DB memory/);
57
58
  assert.match(result.stdout, /matching/);
@@ -77,4 +78,5 @@ test('CLI help Codex commands are dispatchable', () => {
77
78
  test('CLI help exposes the dispatchable read-only handoff context boundary', () => {
78
79
  const bin = readFileSync(BIN_PATH, 'utf8');
79
80
  assert.match(bin, /case 'handoff-context':/);
81
+ assert.match(bin, /case 'latest-session':/);
80
82
  });
@@ -0,0 +1,70 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+
6
+ import { sameProjectPath } from '../project-path.mjs';
7
+
8
+ export const LATEST_SESSION_SCHEMA = 'throughline.latest_session.v1';
9
+
10
+ export function parseArgs(argv = []) {
11
+ if (
12
+ argv.length !== 3 ||
13
+ argv[0] !== '--project' ||
14
+ typeof argv[1] !== 'string' ||
15
+ argv[1].length === 0 ||
16
+ argv[2] !== '--json'
17
+ ) {
18
+ throw new TypeError('usage error');
19
+ }
20
+ return { projectPath: resolve(argv[1]) };
21
+ }
22
+
23
+ export function findLatestSession(projectPath, {
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
+ const rows = db.prepare(
31
+ `SELECT session_id, project_path, updated_at
32
+ FROM sessions
33
+ ORDER BY updated_at DESC`,
34
+ ).all();
35
+ return rows.find((row) => sameProjectPath(row.project_path, projectPath)) ?? null;
36
+ } finally {
37
+ db.close();
38
+ }
39
+ }
40
+
41
+ export function run(argv = [], {
42
+ stdout = process.stdout,
43
+ stderr = process.stderr,
44
+ lookup = findLatestSession,
45
+ } = {}) {
46
+ let projectPath;
47
+ try {
48
+ ({ projectPath } = parseArgs(argv));
49
+ } catch {
50
+ stderr.write('Usage: throughline latest-session --project <absolute-path> --json\n');
51
+ return 2;
52
+ }
53
+
54
+ let session;
55
+ try {
56
+ session = lookup(projectPath);
57
+ } catch {
58
+ stderr.write('Throughline latest session could not be read.\n');
59
+ return 1;
60
+ }
61
+
62
+ stdout.write(`${JSON.stringify({
63
+ schema: LATEST_SESSION_SCHEMA,
64
+ status: session ? 'ready' : 'empty',
65
+ projectPath,
66
+ sessionId: session?.session_id ?? null,
67
+ updatedAt: session?.updated_at ?? null,
68
+ })}\n`);
69
+ return 0;
70
+ }
@@ -0,0 +1,64 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import test from 'node:test';
7
+
8
+ import { findLatestSession, parseArgs, run } from './latest-session.mjs';
9
+
10
+ function output() {
11
+ let value = '';
12
+ return {
13
+ stream: { write(chunk) { value += chunk; } },
14
+ read() { return value; },
15
+ };
16
+ }
17
+
18
+ test('project pathを絶対化して受け取る', () => {
19
+ assert.equal(parseArgs(['--project', '.', '--json']).projectPath, process.cwd());
20
+ assert.throws(() => parseArgs(['--project', '.']));
21
+ });
22
+
23
+ test('同じprojectの最新sessionだけを返す', () => {
24
+ const root = mkdtempSync(join(tmpdir(), 'throughline-latest-session-'));
25
+ const dbPath = join(root, 'throughline.db');
26
+ const db = new DatabaseSync(dbPath);
27
+ db.exec('CREATE TABLE sessions (session_id TEXT, project_path TEXT, updated_at TEXT)');
28
+ db.prepare('INSERT INTO sessions VALUES (?, ?, ?)').run('other-new', join(root, 'other'), '2026-09-01T03:00:00Z');
29
+ db.prepare('INSERT INTO sessions VALUES (?, ?, ?)').run('same-old', join(root, 'bot'), '2026-09-01T01:00:00Z');
30
+ db.prepare('INSERT INTO sessions VALUES (?, ?, ?)').run('same-new', join(root, 'bot'), '2026-09-01T02:00:00Z');
31
+ db.close();
32
+
33
+ try {
34
+ assert.equal(findLatestSession(join(root, 'bot'), { dbPath }).session_id, 'same-new');
35
+ assert.equal(findLatestSession(join(root, 'missing'), { dbPath }), null);
36
+ } finally {
37
+ rmSync(root, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ test('readyとemptyをJSONで返す', () => {
42
+ const stdout = output();
43
+ const stderr = output();
44
+ assert.equal(run(['--project', '/bots/a', '--json'], {
45
+ stdout: stdout.stream,
46
+ stderr: stderr.stream,
47
+ lookup: () => ({ session_id: 'claude:abc', updated_at: 'now' }),
48
+ }), 0);
49
+ assert.deepEqual(JSON.parse(stdout.read()), {
50
+ schema: 'throughline.latest_session.v1',
51
+ status: 'ready',
52
+ projectPath: '/bots/a',
53
+ sessionId: 'claude:abc',
54
+ updatedAt: 'now',
55
+ });
56
+
57
+ const empty = output();
58
+ assert.equal(run(['--project', '/bots/b', '--json'], {
59
+ stdout: empty.stream,
60
+ stderr: stderr.stream,
61
+ lookup: () => null,
62
+ }), 0);
63
+ assert.equal(JSON.parse(empty.read()).status, 'empty');
64
+ });