throughline 0.10.3 → 0.10.6

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +82 -29
  2. package/README.ja.md +79 -47
  3. package/README.md +103 -78
  4. package/bin/throughline.mjs +32 -13
  5. package/docs/00_overview.md +56 -43
  6. package/docs/01_l1_l2_l3_redesign.md +1 -1
  7. package/docs/02_clear_auto_handoff_plan.md +39 -333
  8. package/docs/04_public_release_plan.md +77 -190
  9. package/docs/05_codex_first_roadmap.md +4 -4
  10. package/docs/06_codex_trim_rollback_fix_plan.md +1 -1
  11. package/docs/08_codex_dual_support.md +1 -1
  12. package/docs/09_rollback_context_trim_insight.md +1 -1
  13. package/docs/12_desktop_clear_handoff_plan.md +6 -213
  14. package/docs/15_windows_ci_release_latency_plan.md +6 -87
  15. package/docs/16_readonly_handoff_context_plan.md +7 -38
  16. package/docs/adr/0005-observer-read-pagination.md +1 -1
  17. package/docs/adr/0014-two-phase-handoff-ghost-baton.md +1 -1
  18. package/docs/adr/0019-product-owned-database-migration-acceptance.md +1 -1
  19. package/docs/adr/0021-grok-host-capture.md +1 -1
  20. package/docs/archive/02_clear_auto_handoff_plan.md +350 -0
  21. package/docs/{03_inheritance_on_clear_only.md → archive/03_inheritance_on_clear_only.md} +22 -22
  22. package/docs/{07_codex_trim_implementation_plan.md → archive/07_codex_trim_implementation_plan.md} +8 -8
  23. package/docs/{10_transcript_injection_plan.md → archive/10_transcript_injection_plan.md} +12 -12
  24. package/docs/archive/12_desktop_clear_handoff_plan.md +218 -0
  25. package/docs/{14_observer_completed_turn_feed_plan.md → archive/14_observer_completed_turn_feed_plan.md} +7 -7
  26. package/docs/archive/15_windows_ci_release_latency_plan.md +89 -0
  27. package/docs/archive/16_readonly_handoff_context_plan.md +40 -0
  28. package/docs/archive/README.md +28 -15
  29. package/docs/archive/plan_grok-successor-launch.md +99 -0
  30. package/docs/archive/room-log_throughline_20260830-155052.md +285 -0
  31. package/docs/plan_grok-successor-launch.md +6 -97
  32. package/package.json +11 -4
  33. package/rag/INDEX.md +2 -2
  34. package/src/baton.mjs +11 -9
  35. package/src/cli/handoff-context.test.mjs +36 -0
  36. package/src/cli/help.test.mjs +5 -0
  37. package/src/cli/runtime-errors.mjs +9 -3
  38. package/src/cli/runtime-errors.test.mjs +13 -13
  39. package/src/cli/self-update.mjs +402 -0
  40. package/src/cli/self-update.test.mjs +525 -0
  41. package/src/db.mjs +1 -1
  42. package/src/docs-contract.test.mjs +153 -0
  43. package/src/product-ci-contract.test.mjs +14 -0
  44. package/src/prompt-submit.mjs +8 -10
  45. package/src/resume-context.mjs +4 -4
  46. package/src/runtime-error-hook.test.mjs +4 -6
  47. package/src/runtime-error-store.mjs +40 -18
  48. package/src/runtime-error-store.test.mjs +53 -26
  49. package/src/session-merger.mjs +16 -7
  50. package/src/session-merger.test.mjs +27 -0
  51. package/src/spike-transcript-writer.mjs +1 -1
  52. /package/docs/{11_codex_monitor_implementation_plan.md → archive/11_codex_monitor_implementation_plan.md} +0 -0
  53. /package/docs/{13_native_factory_diagnostics_plan.md → archive/13_native_factory_diagnostics_plan.md} +0 -0
  54. /package/docs/{BUGHUB_RUNTIME_ERROR_STORE_PLAN.md → archive/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md} +0 -0
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { test } from 'node:test';
4
+
5
+ test('windows-native product CI uses PowerShell 7 only', () => {
6
+ const source = readFileSync(new URL('../.github/workflows/product-full-ci.yml', import.meta.url), 'utf8');
7
+ const windowsBlocks = source
8
+ .split(/\n(?= {6}- name:)/)
9
+ .filter((block) => block.includes("matrix.environment == 'windows-native'"));
10
+ assert.equal(windowsBlocks.length, 3);
11
+ for (const block of windowsBlocks) assert.match(block, /\n shell: pwsh\n/);
12
+ assert.match(windowsBlocks[0], /\$env:GITHUB_ENV/);
13
+ assert.doesNotMatch(source, /Git\\bin\\bash|Git\/bin\/bash|shell:\s*(?:powershell|cmd)\b/i);
14
+ });
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * UserPromptSubmit hook — 二相ハンドオフ第二相 + /tl & /clear バトン書き込み + Phase 0-5 spike
3
+ * UserPromptSubmit hook — 二相ハンドオフ第二相 + /tl バトン書き込み + Phase 0-5 spike
4
4
  *
5
5
  * stdin: { session_id, cwd, prompt, transcript_path, hook_event_name, ... }
6
6
  *
@@ -13,23 +13,21 @@
13
13
  * 注入がこの hook に移ったため、SessionStart 側の注入は廃止済み
14
14
  * (旧「二重注入回避」制約はこの構成では発生しない)。
15
15
  * - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
16
- * - prompt が /clear (単独 or /clear ... 形式) で始まっていれば、現セッションの
17
- * session_id をバトンに書き込んで終了。
18
- * (これにより SessionStart 側の findLatestClaudePredecessor heuristic に頼らず、
19
- * 確定的に「/clear が打たれたセッション」を新セッションに引き継げる。複数
20
- * VSCode ウィンドウ等で「最新更新セッション = clear されたセッション」が
21
- * 成立しない multi-window シナリオで誤った前任を選ばないための確定的指名)
16
+ * - `/clear` 文字列を受け取った場合の互換バトン分岐は残す。ただし、実測した
17
+ * Claude Code の組み込み `/clear` はこの hook に届かない。VS Code は
18
+ * SessionStart source='clear' auto path、Desktop は事前 `/tl` を使う。
22
19
  * - それ以外は何もせず exit 0(プロンプトはそのまま Claude に渡る)
23
- * - hook は引き継ぎ注入を行わない (SessionStart の stdout 注入と二重にならないため)
20
+ * - pending intent がある最初のプロンプトでは、本 hook が引き継ぎ注入を行う。
24
21
  *
25
22
  * - **Phase 0-5 spike (SPIKE ONLY)**: marker file `~/.throughline/spike-prompt.flag`
26
23
  * が存在し、当該セッションでまだ spike を打っていなければ、JSONL に user/assistant
27
24
  * 行を chain-reachable (= 直前の attachment uuid を parent に取る) で append する。
28
25
  * SessionStart 経路の spike (chain (a) = orphan) ではモデルに届かなかったため、
29
26
  * UserPromptSubmit 経路で chain (b) を成立させて再検証する。
30
- * docs/10_transcript_injection_plan.md Phase 0-5 参照。
27
+ * docs/archive/10_transcript_injection_plan.md Phase 0-5 参照。
31
28
  *
32
- * 設計背景: docs/03_inheritance_on_clear_only.md バトン方式
29
+ * 設計背景: docs/archive/03_inheritance_on_clear_only.md バトン方式
30
+ * 現行契約: docs/02_clear_auto_handoff_plan.md
33
31
  */
34
32
 
35
33
  import { getDb } from './db.mjs';
@@ -2,16 +2,16 @@
2
2
  * resume-context.mjs — 引継ぎ注入テキストを組み立てる共有モジュール
3
3
  *
4
4
  * 呼び出し元:
5
- * - session-start.mjs (auto path / baton path どちらでも同じ注入)
5
+ * - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
6
6
  *
7
7
  * 設計 (docs/02_clear_auto_handoff_plan.md):
8
- * - 注入順: ヘッダ + 読み方 → 現在地アンカー L1 要約 → L2 本文(一番下)
8
+ * - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
9
+ * 直近L2ターン全文を新しい順にターン原子で詰める。L1は直接注入しない。
9
10
  * - 「現在地」アンカーは直前の user / assistant turn をヘッダ直下に再掲して
10
11
  * 最初の注意を最新ターンに固定する。L2 末尾アンカーは補強として残す。
11
12
  * (L2 が長くなると末尾アンカーだけでは前半の古いターンに注意が固着し、
12
13
  * 話の流れを取り違える事例があった)
13
- * - L3 は別セクションを設けず、対応する L1 / L2 行にインラインで
14
- * `[→ throughline detail HH:MM:SS (kind …)]` ヒントを付ける
14
+ * - L3は本文を注入せず、`throughline detail HH:MM:SS`でpullする
15
15
  * - L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が
16
16
  * 含まれるため、memo / thinking / 末尾の再開指示は注入しない
17
17
  * - 各行頭に [HH:MM:SS] 時刻プレフィックス(L2 は body の created_at、
@@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { defaultFactoryReporterConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
8
+ import { defaultRuntimeErrorConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
9
9
  import { applyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
10
10
 
11
11
  const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
@@ -20,13 +20,11 @@ function createEnabledEnvironment(prefix) {
20
20
  XDG_CONFIG_HOME: join(root, 'config'),
21
21
  XDG_STATE_HOME: join(root, 'state'),
22
22
  };
23
- const configPath = defaultFactoryReporterConfigPath(env);
23
+ const configPath = defaultRuntimeErrorConfigPath(env);
24
24
  mkdirSync(dirname(configPath), { recursive: true });
25
25
  writeFileSync(configPath, JSON.stringify({
26
- schema_version: '1.0',
27
- host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
26
+ schema: 'throughline.runtime_error_config.v1',
28
27
  collection: { enabled: true },
29
- reporting: { enabled: false },
30
28
  }));
31
29
  applyWindowsPrivateAcl(configPath);
32
30
  return { root, env };
@@ -90,7 +88,7 @@ test('store failure preserves product failure and emits only fixed storage diagn
90
88
 
91
89
  test('FIFO config cannot block the original hook failure', { skip: process.platform === 'win32' }, () => {
92
90
  const { env } = createEnabledEnvironment('throughline-runtime-hook-fifo-');
93
- const config = defaultFactoryReporterConfigPath(env);
91
+ const config = defaultRuntimeErrorConfigPath(env);
94
92
  execFileSync('rm', ['-f', config]);
95
93
  execFileSync('mkfifo', [config]);
96
94
  const started = Date.now();
@@ -22,6 +22,7 @@ const require = createRequire(import.meta.url);
22
22
  const PACKAGE_VERSION = require('../package.json').version;
23
23
 
24
24
  export const RUNTIME_ERROR_STORE_SCHEMA = 'throughline.runtime_errors.v1';
25
+ export const RUNTIME_ERROR_CONFIG_SCHEMA = 'throughline.runtime_error_config.v1';
25
26
  export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0';
26
27
  export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unavailable\n';
27
28
  const DEFAULT_SNAPSHOT_LIMIT = 256;
@@ -53,11 +54,11 @@ const DEFINITIONS = Object.freeze({
53
54
  }),
54
55
  });
55
56
 
56
- export function defaultFactoryReporterConfigPath(env = process.env) {
57
+ export function defaultRuntimeErrorConfigPath(env = process.env) {
57
58
  if (isWindows(env)) {
58
- return join(windowsLocalAppData(env), 'dotagents', 'factory-reporter', 'config.json');
59
+ return join(windowsLocalAppData(env), 'throughline', 'runtime-errors.config.json');
59
60
  }
60
- return join(xdgConfigHome(env), 'dotagents', 'factory-reporter.json');
61
+ return join(xdgConfigHome(env), 'throughline', 'runtime-errors.config.json');
61
62
  }
62
63
 
63
64
  export function defaultRuntimeErrorStorePath(env = process.env) {
@@ -69,16 +70,46 @@ export function defaultRuntimeErrorStorePath(env = process.env) {
69
70
 
70
71
  export function isRuntimeErrorCollectionEnabled({ env = process.env, configPath } = {}) {
71
72
  try {
72
- const path = configPath || defaultFactoryReporterConfigPath(env);
73
+ const path = configPath || defaultRuntimeErrorConfigPath(env);
73
74
  const info = lstatSync(path);
74
75
  if (!info.isFile() || info.isSymbolicLink()) return false;
75
76
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
76
- return isCanonicalFactoryReporterConfig(parsed) && parsed.collection.enabled === true;
77
+ return isCanonicalRuntimeErrorConfig(parsed) && parsed.collection.enabled === true;
77
78
  } catch {
78
79
  return false;
79
80
  }
80
81
  }
81
82
 
83
+ export function setRuntimeErrorCollectionEnabled(enabled, options = {}) {
84
+ assertExactOptions(options, ['env', 'configPath']);
85
+ if (typeof enabled !== 'boolean') throw new TypeError('enabled は boolean が必要です');
86
+ const env = options.env ?? process.env;
87
+ const path = options.configPath || defaultRuntimeErrorConfigPath(env);
88
+ const directory = dirname(path);
89
+ ensurePrivateStoreDirectory(directory, env);
90
+ const temporary = join(directory, `.runtime-errors-config.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
91
+ const config = {
92
+ schema: RUNTIME_ERROR_CONFIG_SCHEMA,
93
+ collection: { enabled },
94
+ };
95
+ try {
96
+ writeFileSync(temporary, `${JSON.stringify(config)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
97
+ if (isWindows(env)) {
98
+ applyAndVerifyWindowsAcl(temporary, false);
99
+ assertPrivateStoreFileShape(lstatSync(temporary));
100
+ } else {
101
+ chmodSync(temporary, 0o600);
102
+ assertPrivateStoreFile(lstatSync(temporary), env, temporary);
103
+ }
104
+ renameSync(temporary, path);
105
+ if (isWindows(env)) assertPrivateStoreFileShape(lstatSync(path));
106
+ else assertPrivateStoreFile(lstatSync(path), env, path);
107
+ return config;
108
+ } finally {
109
+ rmSync(temporary, { force: true });
110
+ }
111
+ }
112
+
82
113
  export function observeRuntimeError(input, options = {}) {
83
114
  assertExactInput(input, ['code', 'now']);
84
115
  assertExactOptions(options, ['env', 'configPath', 'storePath', 'version', 'platform', 'arch']);
@@ -410,20 +441,11 @@ function assertPrivateStoreFileShape(info) {
410
441
  }
411
442
  }
412
443
 
413
- function isCanonicalFactoryReporterConfig(value) {
414
- if (!isPlainObject(value) || !exactKeys(value, ['schema_version', 'host', 'collection', 'reporting']) || value.schema_version !== '1.0') return false;
415
- if (!isPlainObject(value.host) || !exactKeys(value.host, ['id', 'profile']) ||
416
- typeof value.host.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value.host.id) ||
417
- !['server', 'mac', 'wsl', 'windows-native'].includes(value.host.profile)) return false;
444
+ function isCanonicalRuntimeErrorConfig(value) {
445
+ if (!isPlainObject(value) || !exactKeys(value, ['schema', 'collection']) ||
446
+ value.schema !== RUNTIME_ERROR_CONFIG_SCHEMA) return false;
418
447
  if (!isPlainObject(value.collection) || !exactKeys(value.collection, ['enabled']) || typeof value.collection.enabled !== 'boolean') return false;
419
- if (!isPlainObject(value.reporting) || !exactKeys(value.reporting, ['enabled', 'endpoint', 'credential_file'], true) || typeof value.reporting.enabled !== 'boolean') return false;
420
- if (value.reporting.endpoint !== undefined) {
421
- if (typeof value.reporting.endpoint !== 'string' || value.reporting.endpoint.length > 2048) return false;
422
- try { if (!['http:', 'https:'].includes(new URL(value.reporting.endpoint).protocol)) return false; } catch { return false; }
423
- }
424
- if (value.reporting.credential_file !== undefined &&
425
- (typeof value.reporting.credential_file !== 'string' || value.reporting.credential_file.length < 1 || value.reporting.credential_file.length > 4096)) return false;
426
- return !value.reporting.enabled || (value.reporting.endpoint !== undefined && value.reporting.credential_file !== undefined);
448
+ return true;
427
449
  }
428
450
 
429
451
  function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
@@ -8,13 +8,14 @@ import { dirname, join } from 'node:path';
8
8
  import {
9
9
  acknowledgeRuntimeErrors,
10
10
  compactRuntimeErrors,
11
- defaultFactoryReporterConfigPath,
11
+ defaultRuntimeErrorConfigPath,
12
12
  defaultRuntimeErrorStorePath,
13
13
  getRuntimeErrorDiagnostics,
14
14
  observeRuntimeError,
15
15
  readRuntimeErrorSnapshot,
16
16
  reopenRuntimeError,
17
17
  resolveRuntimeError,
18
+ setRuntimeErrorCollectionEnabled,
18
19
  } from './runtime-error-store.mjs';
19
20
  import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
20
21
 
@@ -29,18 +30,16 @@ function sandbox() {
29
30
  XDG_CONFIG_HOME: join(root, 'config'),
30
31
  XDG_STATE_HOME: join(root, 'state'),
31
32
  };
32
- const configPath = defaultFactoryReporterConfigPath(env);
33
+ const configPath = defaultRuntimeErrorConfigPath(env);
33
34
  const storePath = defaultRuntimeErrorStorePath(env);
34
35
  return { root, env, configPath, storePath };
35
36
  }
36
37
 
37
- function enableCollection(box, reporting = { enabled: false }) {
38
+ function enableCollection(box) {
38
39
  mkdirSync(dirname(box.configPath), { recursive: true });
39
40
  writeFileSync(box.configPath, JSON.stringify({
40
- schema_version: '1.0',
41
- host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
41
+ schema: 'throughline.runtime_error_config.v1',
42
42
  collection: { enabled: true },
43
- reporting,
44
43
  }));
45
44
  applyWindowsPrivateAcl(box.configPath);
46
45
  }
@@ -49,8 +48,8 @@ test('runtime error store: missing/false/malformed config is fail-closed and cre
49
48
  for (const config of [
50
49
  null,
51
50
  { collection: { enabled: true } },
52
- { collection: { enabled: false } },
53
- { collection: { enabled: 'true' } },
51
+ { schema: 'throughline.runtime_error_config.v1', collection: { enabled: false } },
52
+ { schema: 'throughline.runtime_error_config.v1', collection: { enabled: 'true' } },
54
53
  '{malformed',
55
54
  ]) {
56
55
  const box = sandbox();
@@ -67,15 +66,15 @@ test('runtime error store: missing/false/malformed config is fail-closed and cre
67
66
  }
68
67
  });
69
68
 
70
- test('runtime error store: Windows native uses the canonical LocalAppData paths', () => {
69
+ test('runtime error store: Windows native uses product-owned LocalAppData paths', () => {
71
70
  const env = {
72
71
  OS: 'Windows_NT',
73
72
  USERPROFILE: 'C:\\Users\\kite_',
74
73
  LOCALAPPDATA: 'C:\\Users\\kite_\\AppData\\Local',
75
74
  };
76
75
  assert.equal(
77
- defaultFactoryReporterConfigPath(env),
78
- join(env.LOCALAPPDATA, 'dotagents', 'factory-reporter', 'config.json'),
76
+ defaultRuntimeErrorConfigPath(env),
77
+ join(env.LOCALAPPDATA, 'throughline', 'runtime-errors.config.json'),
79
78
  );
80
79
  assert.equal(
81
80
  defaultRuntimeErrorStorePath(env),
@@ -83,6 +82,34 @@ test('runtime error store: Windows native uses the canonical LocalAppData paths'
83
82
  );
84
83
  });
85
84
 
85
+ test('runtime error store: product-owned config enables and disables collection', () => {
86
+ const box = sandbox();
87
+ assert.deepEqual(setRuntimeErrorCollectionEnabled(true, { env: box.env }), {
88
+ schema: 'throughline.runtime_error_config.v1',
89
+ collection: { enabled: true },
90
+ });
91
+ assert.equal(observeRuntimeError({ code: 'HOOK_PROCESS_TURN_FAILED' }, { env: box.env }).status, 'recorded');
92
+ assert.deepEqual(setRuntimeErrorCollectionEnabled(false, { env: box.env }), {
93
+ schema: 'throughline.runtime_error_config.v1',
94
+ collection: { enabled: false },
95
+ });
96
+ assert.equal(getRuntimeErrorDiagnostics({ env: box.env }).collection, 'disabled');
97
+ });
98
+
99
+ test('runtime error store: legacy dotagents config cannot control collection', () => {
100
+ const box = sandbox();
101
+ const legacyPath = join(box.env.XDG_CONFIG_HOME, 'dotagents', 'factory-reporter.json');
102
+ mkdirSync(dirname(legacyPath), { recursive: true });
103
+ writeFileSync(legacyPath, JSON.stringify({
104
+ schema_version: '1.0',
105
+ host: { id: 'test-host', profile: 'mac' },
106
+ collection: { enabled: true },
107
+ reporting: { enabled: false },
108
+ }));
109
+ assert.equal(observeRuntimeError({ code: 'HOOK_PROCESS_TURN_FAILED' }, { env: box.env }).status, 'disabled');
110
+ assert.throws(() => statSync(box.storePath), { code: 'ENOENT' });
111
+ });
112
+
86
113
  test('runtime error store: one Windows mutation spends ACL processes only on distinct state transitions', (t) => {
87
114
  const box = sandbox();
88
115
  box.env.OS = 'Windows_NT';
@@ -127,20 +154,26 @@ test('runtime error store: Windows temporary ACL failure leaves the previous ato
127
154
  assert.deepEqual(readdirSync(dirname(box.storePath)).filter((name) => name.endsWith('.tmp')), []);
128
155
  });
129
156
 
130
- test('runtime error store: reporting config and credentials are ignored and no network API is accepted', () => {
157
+ test('runtime error store: factory reporting fields cannot enable product collection', () => {
131
158
  const box = sandbox();
132
- enableCollection(box, {
133
- enabled: true,
134
- endpoint: 'https://should-never-be-read.invalid/private',
135
- credential_file: process.platform === 'win32' ? 'C:\\private\\token' : '/private/token',
136
- });
159
+ mkdirSync(dirname(box.configPath), { recursive: true });
160
+ writeFileSync(box.configPath, JSON.stringify({
161
+ schema_version: '1.0',
162
+ host: { id: 'test-host', profile: 'mac' },
163
+ collection: { enabled: true },
164
+ reporting: {
165
+ enabled: true,
166
+ endpoint: 'https://should-never-be-read.invalid/private',
167
+ credential_file: '/private/token',
168
+ },
169
+ }));
170
+ applyWindowsPrivateAcl(box.configPath);
137
171
  const result = observeRuntimeError(
138
172
  { code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-07-13T00:00:00.000Z' },
139
173
  { env: box.env, version: '0.6.1', platform: TEST_PLATFORM, arch: 'arm64' },
140
174
  );
141
- assert.equal(result.status, 'recorded');
142
- const bytes = readFileSync(box.storePath, 'utf8');
143
- assert.doesNotMatch(bytes, /should-never-be-read|private\/token|endpoint|credential/i);
175
+ assert.equal(result.status, 'disabled');
176
+ assert.throws(() => statSync(box.storePath), { code: 'ENOENT' });
144
177
  });
145
178
 
146
179
  test('runtime error store: observation API rejects raw or arbitrary fields', () => {
@@ -294,12 +327,6 @@ test('runtime error store: atomic private store has owner-only modes and bounded
294
327
  assert.doesNotMatch(JSON.stringify(diagnostics), /Users|\\|\.throughline|runtime-errors\.json/);
295
328
  });
296
329
 
297
- test('runtime error store: non-canonical reporting values keep collection fail-closed', () => {
298
- const box = sandbox();
299
- enableCollection(box, { enabled: true, endpoint: 'ftp://invalid', credential_file: '' });
300
- assert.deepEqual(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, { env: box.env }), { status: 'disabled' });
301
- });
302
-
303
330
  test('runtime error store: unknown top-level fields and future ack are rejected before compaction', () => {
304
331
  const box = sandbox();
305
332
  enableCollection(box);
@@ -2,15 +2,19 @@
2
2
  * session-merger.mjs — 記憶張り替え + merged_into チェーン解決
3
3
  *
4
4
  * 用途:
5
- * - SessionStart hook: バトンで指名された旧セッションを mergeSpecificPredecessor で新セッションに張り替え
5
+ * - 初回 UserPromptSubmit: baton または source='clear' で凍結した旧セッションを
6
+ * mergeSpecificPredecessor で新セッションに張り替え
6
7
  * - Stop hook: resolveMergeTarget で「入力 session_id → 実書き込み先」を解決
7
8
  *
8
- * 設計背景: docs/archive/SESSION_LINKING_DESIGN.md, docs/03_inheritance_on_clear_only.md (バトン方式採用)
9
+ * 設計背景: docs/archive/SESSION_LINKING_DESIGN.md, docs/archive/03_inheritance_on_clear_only.md (バトン方式採用)
9
10
  *
10
- * 旧実装 (案 D: 時間差ヒューリスティック / 自動前任選択) は撤去済み。
11
- * 引き継ぎはユーザーが /tl を打って書いたバトンによる明示的指名のみで発火する。
11
+ * 旧実装の無条件な時間差ヒューリスティックは撤去済み。
12
+ * 引き継ぎは `/tl` baton、または VS Code `/clear` の `source='clear'` で
13
+ * SessionStart 時に凍結した transcript-backed predecessor からだけ発火する。
12
14
  */
13
15
 
16
+ import { sameProjectPath } from './project-path.mjs';
17
+
14
18
  const MAX_CHAIN_DEPTH = 10;
15
19
 
16
20
  /**
@@ -61,7 +65,7 @@ export function resolveMergeTarget(db, sessionId) {
61
65
  * merged: boolean,
62
66
  * predecessorId?: string,
63
67
  * rowCounts?: { sk: number, dt: number, bd: number },
64
- * skipReason?: 'self_handoff' | 'predecessor_not_found' | 'already_merged' | 'predecessor_not_older',
68
+ * skipReason?: 'self_handoff' | 'predecessor_not_found' | 'already_merged' | 'project_mismatch' | 'predecessor_not_older',
65
69
  * }}
66
70
  */
67
71
  export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now = Date.now() }) {
@@ -72,7 +76,7 @@ export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now
72
76
  db.exec('BEGIN IMMEDIATE');
73
77
  try {
74
78
  const pred = db
75
- .prepare('SELECT session_id, created_at, merged_into FROM sessions WHERE session_id = ?')
79
+ .prepare('SELECT session_id, project_path, created_at, merged_into FROM sessions WHERE session_id = ?')
76
80
  .get(predecessorId);
77
81
 
78
82
  if (!pred) {
@@ -85,9 +89,14 @@ export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now
85
89
  }
86
90
 
87
91
  const self = db
88
- .prepare('SELECT created_at FROM sessions WHERE session_id = ?')
92
+ .prepare('SELECT project_path, created_at FROM sessions WHERE session_id = ?')
89
93
  .get(newSessionId);
90
94
 
95
+ if (self && !sameProjectPath(pred.project_path, self.project_path)) {
96
+ db.exec('COMMIT');
97
+ return { merged: false, skipReason: 'project_mismatch' };
98
+ }
99
+
91
100
  // 時系列単調制約: 前任は新セッションより created_at が古いこと。
92
101
  // バトンが自分より新しい session を指していたら(異常データ)merge しない。
93
102
  if (self && pred.created_at >= self.created_at) {
@@ -151,6 +151,33 @@ test('mergeSpecificPredecessor: predecessor already merged into third session',
151
151
  assert.equal(result.skipReason, 'already_merged');
152
152
  });
153
153
 
154
+ test('mergeSpecificPredecessor: refuses to move memory across projects', () => {
155
+ const db = makeDb();
156
+ insertSession(db, 'bot-a-old', 100, null, '/bots/bot-a');
157
+ insertSession(db, 'bot-b-new', 200, null, '/bots/bot-b');
158
+ db.prepare(
159
+ `INSERT INTO bodies
160
+ (session_id, origin_session_id, turn_number, role, text, created_at)
161
+ VALUES ('bot-a-old', 'bot-a-old', 1, 'user', 'A_PRIVATE_MEMORY', 100)`,
162
+ ).run();
163
+
164
+ const result = mergeSpecificPredecessor(db, {
165
+ newSessionId: 'bot-b-new',
166
+ predecessorId: 'bot-a-old',
167
+ now: 200,
168
+ });
169
+
170
+ assert.deepEqual(result, { merged: false, skipReason: 'project_mismatch' });
171
+ assert.equal(
172
+ db.prepare('SELECT session_id FROM bodies WHERE text = ?').get('A_PRIVATE_MEMORY').session_id,
173
+ 'bot-a-old',
174
+ );
175
+ assert.equal(
176
+ db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('bot-a-old').merged_into,
177
+ null,
178
+ );
179
+ });
180
+
154
181
  test('mergeSpecificPredecessor: refuses predecessor with created_at >= self', () => {
155
182
  const db = makeDb();
156
183
  insertSession(db, 'new', 100);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SPIKE ONLY — Phase 0-2 / 0-4 検証用。本実装ではない。
3
3
  *
4
- * docs/10_transcript_injection_plan.md Phase 0-2 で
4
+ * docs/archive/10_transcript_injection_plan.md Phase 0-2 で
5
5
  * 「`/clear` 直後の SessionStart hook 内で transcript_path に L2 を user/assistant
6
6
  * role 付きで append すると、Claude が次の short prompt の文脈として読むか」を実機検証する。
7
7
  *