throughline 0.12.8 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/README.ja.md +20 -0
- package/README.md +20 -0
- package/bin/throughline.mjs +10 -1
- package/docs/02_clear_auto_handoff_plan.md +2 -1
- package/docs/05_codex_first_roadmap.md +2 -0
- package/docs/adr/0031-serialized-schema-migration.md +43 -0
- package/docs/adr/0032-claude-compact-continuation.md +88 -0
- package/package.json +1 -1
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +56 -0
- package/rag/INDEX.md +4 -0
- package/src/claude-auto-handoff-config.mjs +38 -0
- package/src/claude-auto-handoff.mjs +207 -0
- package/src/claude-auto-handoff.test.mjs +308 -0
- package/src/claude-pre-compact.mjs +55 -0
- package/src/cli/auto-handoff.mjs +43 -5
- package/src/cli/install.mjs +28 -0
- package/src/cli/install.test.mjs +6 -0
- package/src/db-contention.test.mjs +27 -0
- package/src/db.mjs +62 -10
- package/src/hook-entrypoints.test.mjs +3 -1
- package/src/hook-failure-log.mjs +17 -1
- package/src/hook-failure-log.test.mjs +36 -2
- package/src/hosts/cursor.test.mjs +10 -0
- package/src/hosts/index.mjs +9 -1
- package/src/resume-context.mjs +101 -5
- package/src/resume-context.test.mjs +72 -0
- package/src/runtime-error-store.mjs +6 -0
- package/src/session-start.mjs +33 -1
- package/src/transcript-reader.mjs +58 -2
- package/src/transcript-reader.test.mjs +90 -0
- package/src/turn-backfill.mjs +7 -2
- package/src/turn-backfill.test.mjs +58 -0
package/src/cli/install.mjs
CHANGED
|
@@ -36,6 +36,7 @@ const SC_COMMANDS = [
|
|
|
36
36
|
'throughline process-turn',
|
|
37
37
|
'throughline session-start',
|
|
38
38
|
'throughline prompt-submit',
|
|
39
|
+
'throughline pre-compact',
|
|
39
40
|
// 旧コマンド(アンインストール時に除去する)
|
|
40
41
|
'throughline inject-context',
|
|
41
42
|
'throughline capture-tool',
|
|
@@ -55,8 +56,15 @@ const SC_HOOKS = {
|
|
|
55
56
|
UserPromptSubmit: {
|
|
56
57
|
hooks: [{ type: 'command', command: 'throughline prompt-submit' }],
|
|
57
58
|
},
|
|
59
|
+
// 自動継続の第一段 (ADR 0032)。matcher は付けない: 手動の /compact でも呼ばれ、古い印を消す。
|
|
60
|
+
// 自動継続が無効な時は何も残さずに抜ける。
|
|
61
|
+
PreCompact: {
|
|
62
|
+
hooks: [{ type: 'command', command: 'throughline pre-compact' }],
|
|
63
|
+
},
|
|
58
64
|
};
|
|
59
65
|
|
|
66
|
+
const CLAUDE_PRE_COMPACT_COMMAND = SC_HOOKS.PreCompact.hooks[0].command;
|
|
67
|
+
|
|
60
68
|
const CODEX_COMMANDS = [
|
|
61
69
|
'throughline codex-hook stop',
|
|
62
70
|
'throughline codex-hook user-prompt-submit',
|
|
@@ -629,6 +637,25 @@ function installCodexHooks() {
|
|
|
629
637
|
return { hooksPath, configPath };
|
|
630
638
|
}
|
|
631
639
|
|
|
640
|
+
export function claudeAutoHandoffHookRegistered(settingsPath = join(homedir(), '.claude', 'settings.json')) {
|
|
641
|
+
const groups = readSettings(settingsPath).hooks?.PreCompact ?? [];
|
|
642
|
+
// matcher 付きの登録は手動の /compact で呼ばれず、古い印が残る。matcher 無しだけを登録済みとする。
|
|
643
|
+
return groups.some(group => !group.matcher && (group.hooks ?? []).some(hook => hook.command === CLAUDE_PRE_COMPACT_COMMAND));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** `auto-handoff enable --host claude` 用。古い版で install した端末にも PreCompact hook を足す。 */
|
|
647
|
+
export function installClaudeAutoHandoffHook(settingsPath = join(homedir(), '.claude', 'settings.json')) {
|
|
648
|
+
if (!claudeAutoHandoffHookRegistered(settingsPath)) {
|
|
649
|
+
const current = readSettings(settingsPath);
|
|
650
|
+
current.hooks ??= {};
|
|
651
|
+
const preserved = (current.hooks.PreCompact ?? []).map(group => ({ ...group,
|
|
652
|
+
hooks: (group.hooks ?? []).filter(hook => hook.command !== CLAUDE_PRE_COMPACT_COMMAND) })).filter(group => group.hooks.length);
|
|
653
|
+
current.hooks.PreCompact = [SC_HOOKS.PreCompact, ...preserved];
|
|
654
|
+
writeSettings(settingsPath, current);
|
|
655
|
+
}
|
|
656
|
+
return { settingsPath, command: CLAUDE_PRE_COMPACT_COMMAND };
|
|
657
|
+
}
|
|
658
|
+
|
|
632
659
|
export function installCodexAutoHandoffHook(codexHome = join(homedir(), '.codex')) {
|
|
633
660
|
const hooksPath = join(codexHome, 'hooks.json');
|
|
634
661
|
const configPath = join(codexHome, 'config.toml');
|
|
@@ -777,6 +804,7 @@ export async function run(args = []) {
|
|
|
777
804
|
console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
|
|
778
805
|
console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
|
|
779
806
|
console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
|
|
807
|
+
console.log(' PreCompact → throughline pre-compact (自動継続の印。既定は無効: auto-handoff enable --host claude)');
|
|
780
808
|
if (codex) {
|
|
781
809
|
console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (capture / monitor state only; auto refresh disabled)`);
|
|
782
810
|
console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (capture / monitor state only; auto refresh disabled)`);
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -667,6 +667,12 @@ test('Claude Stop hook is registered with async:true so it does not block ター
|
|
|
667
667
|
.flatMap(g => g.hooks ?? [])
|
|
668
668
|
.find(h => h.command === 'throughline prompt-submit');
|
|
669
669
|
assert.notEqual(promptSubmitHook.async, true, 'UserPromptSubmit stays synchronous (needs baton write committed before turn)');
|
|
670
|
+
// 自動継続の印 (ADR 0032)。matcher 無し = 手動の /compact でも呼ばれて古い印を消す。
|
|
671
|
+
assert.deepEqual(settings.hooks.PreCompact, [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }]);
|
|
672
|
+
|
|
673
|
+
await run(['--uninstall']);
|
|
674
|
+
const removed = JSON.parse(readFileSync(join(home.dir, '.claude', 'settings.json'), 'utf8'));
|
|
675
|
+
assert.equal(removed.hooks, undefined, 'uninstall removes every Throughline Claude hook including PreCompact');
|
|
670
676
|
} finally {
|
|
671
677
|
unsilence();
|
|
672
678
|
home.restore();
|
|
@@ -93,3 +93,30 @@ test('getDb reuses an existing WAL database while another process owns the write
|
|
|
93
93
|
rmSync(home, { recursive: true, force: true });
|
|
94
94
|
}
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
test('getDbは、新しいDBを複数のprocessが同時に開いても、全員が現行schemaで開ける', async () => {
|
|
98
|
+
// WindowsのCursorの会話では同じhookが2本ほぼ同時に走る。初回はどのprocessも版0を読むので、lockの外で読んだ版から
|
|
99
|
+
// 移行をやり直すと、先に進んだprocessが消した表へ触って落ちた(修理前は6本で半分ほどの確率)。
|
|
100
|
+
const rounds = 6;
|
|
101
|
+
const processes = 6;
|
|
102
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
103
|
+
const home = mkdtempSync(join(tmpdir(), 'throughline-db-first-open-'));
|
|
104
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
105
|
+
try {
|
|
106
|
+
const results = await Promise.all(Array.from({ length: processes }, () => {
|
|
107
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
108
|
+
import { getDb, CURRENT_VERSION } from ${JSON.stringify(DB_MODULE_URL)};
|
|
109
|
+
const db = getDb();
|
|
110
|
+
const version = db.prepare('PRAGMA user_version').get().user_version;
|
|
111
|
+
db.prepare('SELECT count(*) AS n FROM sessions').get();
|
|
112
|
+
db.close();
|
|
113
|
+
if (version !== CURRENT_VERSION) process.exit(2);
|
|
114
|
+
`], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
115
|
+
return waitForExit(child);
|
|
116
|
+
}));
|
|
117
|
+
assert.deepEqual(results, Array.from({ length: processes }, () => ({ code: 0, stderr: '' })));
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(home, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
package/src/db.mjs
CHANGED
|
@@ -25,10 +25,68 @@ export function openReadOnlyDb(path = DB_PATH) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const WAL_SWITCH_RETRY_MS = 25;
|
|
29
|
+
|
|
30
|
+
function isSqliteBusy(error) {
|
|
31
|
+
if (typeof error?.errcode === 'number') return (error.errcode & 0xff) === 5;
|
|
32
|
+
return /database is locked/u.test(String(error?.message ?? ''));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sleepSync(ms) {
|
|
36
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* journal mode を WAL にする。既に WAL なら何も書かない。
|
|
41
|
+
*
|
|
42
|
+
* WAL への切り替えは排他 lock を要り、他の process が同じ DB を開いている間は busy_timeout を待たずに
|
|
43
|
+
* `database is locked` で断られる。新しい DB を複数の hook が同時に開く時に起きるので、
|
|
44
|
+
* busy_timeout と同じ時間まで読み直す (ADR 0031)。他の process が先に切り替えれば、読み直しで WAL が見える。
|
|
45
|
+
*/
|
|
46
|
+
function ensureWalJournalMode(db) {
|
|
47
|
+
const deadline = Date.now() + DB_BUSY_TIMEOUT_MS;
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
const journalMode = db.prepare('PRAGMA journal_mode').get().journal_mode;
|
|
51
|
+
if (String(journalMode).toLowerCase() === 'wal') return;
|
|
52
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
53
|
+
return;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
|
|
56
|
+
sleepSync(WAL_SWITCH_RETRY_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readSchemaVersion(db) {
|
|
62
|
+
return db.prepare('PRAGMA user_version').get().user_version ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* schema を現行版へ上げる。現行版なら何も書かない。
|
|
67
|
+
*
|
|
68
|
+
* 移行は書き込み lock を取ってから版を読み直し、1つの transaction で終える (ADR 0031)。
|
|
69
|
+
* lock の外で読んだ版は、他の process が移行を進めた後では古い。古い版から移行をやり直すと、
|
|
70
|
+
* 途中の版で消した表(v4 の judgments)へ触って `no such table` で落ちる。
|
|
71
|
+
*/
|
|
28
72
|
function initSchema(db) {
|
|
29
|
-
|
|
30
|
-
|
|
73
|
+
if (readSchemaVersion(db) >= CURRENT_VERSION) return;
|
|
74
|
+
db.exec('BEGIN IMMEDIATE');
|
|
75
|
+
try {
|
|
76
|
+
const version = readSchemaVersion(db);
|
|
77
|
+
if (version < CURRENT_VERSION) applySchemaMigrations(db, version);
|
|
78
|
+
db.exec('COMMIT');
|
|
79
|
+
} catch (error) {
|
|
80
|
+
try {
|
|
81
|
+
db.exec('ROLLBACK');
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
31
88
|
|
|
89
|
+
function applySchemaMigrations(db, version) {
|
|
32
90
|
// v0 → v1: 全テーブル作成
|
|
33
91
|
if (version < 1) {
|
|
34
92
|
db.exec(`
|
|
@@ -353,10 +411,7 @@ export function migrateDefaultDb() {
|
|
|
353
411
|
};
|
|
354
412
|
}
|
|
355
413
|
|
|
356
|
-
|
|
357
|
-
if (String(journalMode).toLowerCase() !== 'wal') {
|
|
358
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
359
|
-
}
|
|
414
|
+
ensureWalJournalMode(db);
|
|
360
415
|
initSchema(db);
|
|
361
416
|
const afterSchemaVersion = Number(db.prepare('PRAGMA user_version').get().user_version ?? 0);
|
|
362
417
|
if (afterSchemaVersion !== CURRENT_VERSION) {
|
|
@@ -397,10 +452,7 @@ export function getDb() {
|
|
|
397
452
|
const db = new DatabaseSync(DB_PATH);
|
|
398
453
|
try {
|
|
399
454
|
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
400
|
-
|
|
401
|
-
if (String(journalMode).toLowerCase() !== 'wal') {
|
|
402
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
403
|
-
}
|
|
455
|
+
ensureWalJournalMode(db);
|
|
404
456
|
db.exec('PRAGMA foreign_keys = ON');
|
|
405
457
|
initSchema(db);
|
|
406
458
|
_db = db;
|
|
@@ -976,7 +976,9 @@ test('a failed hook leaves its reason in the local hook failure log', () => {
|
|
|
976
976
|
['HOOK_SESSION_START_FAILED', 'Error', 'Missing session_id in SessionStart payload'],
|
|
977
977
|
]);
|
|
978
978
|
for (const entry of entries) {
|
|
979
|
-
assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message']);
|
|
979
|
+
assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message', 'stack']);
|
|
980
|
+
// 失敗した位置は、payloadを検査して投げたhookの入口を指す。
|
|
981
|
+
assert.match(entry.stack[0], /^at .*(turn-processor|session-start)\.mjs:\d+:\d+\)?$/);
|
|
980
982
|
assert.match(entry.ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
981
983
|
assert.match(entry.version, /^\d+\.\d+\.\d+$/);
|
|
982
984
|
}
|
package/src/hook-failure-log.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* runtime error store は外へ送れる形(定型 code と回数)だけを持つ (ADR 0025)。失敗した時の
|
|
5
5
|
* 例外の文面はどこにも残らず、後から原因を追えなかった(2026-10-04、Mac と main-server の
|
|
6
|
-
* 7〜8
|
|
6
|
+
* 7〜8月の失敗)。このログは理由と失敗した位置(stack の先頭)を端末内にだけ残す。runtime error の収集・送信の設定とは
|
|
7
7
|
* 独立で、外へは送らない。
|
|
8
8
|
* 書き込み失敗は stderr に出す(黙って握りつぶさない)。
|
|
9
9
|
*/
|
|
@@ -17,11 +17,23 @@ const require = createRequire(import.meta.url);
|
|
|
17
17
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
18
18
|
|
|
19
19
|
export const HOOK_FAILURE_MESSAGE_LIMIT = 1000;
|
|
20
|
+
export const HOOK_FAILURE_STACK_FRAMES = 6;
|
|
21
|
+
export const HOOK_FAILURE_STACK_FRAME_LIMIT = 300;
|
|
20
22
|
|
|
21
23
|
export function hookFailureLogPath({ home = homedir() } = {}) {
|
|
22
24
|
return join(home, '.throughline', 'logs', 'hook-failures.log');
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function stackFrames(error) {
|
|
28
|
+
if (!(error instanceof Error) || typeof error.stack !== 'string') return [];
|
|
29
|
+
return error.stack
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter((line) => line.startsWith('at '))
|
|
33
|
+
.slice(0, HOOK_FAILURE_STACK_FRAMES)
|
|
34
|
+
.map((line) => line.slice(0, HOOK_FAILURE_STACK_FRAME_LIMIT));
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* @param {string} code runtime error の定型 code(例: HOOK_PROCESS_TURN_FAILED)
|
|
27
39
|
* @param {unknown} error 失敗の原因
|
|
@@ -36,6 +48,10 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
|
|
|
36
48
|
name: error instanceof Error ? error.name : typeof error,
|
|
37
49
|
message: message.slice(0, HOOK_FAILURE_MESSAGE_LIMIT),
|
|
38
50
|
};
|
|
51
|
+
// 失敗した位置を残す。`database is locked` のような文面だけでは、DB を開く時か書く時か、
|
|
52
|
+
// どの処理で落ちたかが分からない(2026-10-04、Windows の SessionStart と UserPromptSubmit の失敗)。
|
|
53
|
+
const frames = stackFrames(error);
|
|
54
|
+
if (frames.length > 0) entry.stack = frames;
|
|
39
55
|
// どの会話の失敗かを残す(errorが `hookContext` を持つ時)。回数と文面だけでは、失敗した会話を
|
|
40
56
|
// 端末の他の記録から探すことになる。
|
|
41
57
|
const context = error?.hookContext;
|
|
@@ -3,7 +3,13 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
HOOK_FAILURE_MESSAGE_LIMIT,
|
|
8
|
+
HOOK_FAILURE_STACK_FRAMES,
|
|
9
|
+
HOOK_FAILURE_STACK_FRAME_LIMIT,
|
|
10
|
+
hookFailureLogPath,
|
|
11
|
+
logHookFailure,
|
|
12
|
+
} from './hook-failure-log.mjs';
|
|
7
13
|
|
|
8
14
|
test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
9
15
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
@@ -12,9 +18,12 @@ test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
|
12
18
|
logHookFailure('HOOK_CODEX_FAILED', 'thrown string', { home, now: Date.UTC(2026, 9, 4, 0, 0, 1) });
|
|
13
19
|
const lines = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
14
20
|
assert.equal(lines.length, 2);
|
|
15
|
-
|
|
21
|
+
const { stack, ...first } = lines[0];
|
|
22
|
+
assert.deepEqual({ ...first, version: 'x' }, {
|
|
16
23
|
ts: '2026-10-04T00:00:00.005Z', code: 'HOOK_PROCESS_TURN_FAILED', version: 'x', name: 'TypeError', message: 'first reason',
|
|
17
24
|
});
|
|
25
|
+
// Errorは失敗した位置を持つ。文字列を投げた時はstackが無いので、項目ごと残さない。
|
|
26
|
+
assert.equal(Array.isArray(stack) && stack.length > 0, true);
|
|
18
27
|
assert.deepEqual({ ...lines[1], version: 'x' }, {
|
|
19
28
|
ts: '2026-10-04T00:00:01.000Z', code: 'HOOK_CODEX_FAILED', version: 'x', name: 'string', message: 'thrown string',
|
|
20
29
|
});
|
|
@@ -61,6 +70,31 @@ test('logHookFailureは、どの会話の失敗かを残す', () => {
|
|
|
61
70
|
}
|
|
62
71
|
});
|
|
63
72
|
|
|
73
|
+
test('logHookFailureは、失敗した位置をstackの先頭から残す', () => {
|
|
74
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
75
|
+
try {
|
|
76
|
+
const err = new Error('database is locked');
|
|
77
|
+
err.stack = [
|
|
78
|
+
'Error: database is locked',
|
|
79
|
+
' at getDb (file:///throughline/src/db.mjs:455:5)',
|
|
80
|
+
` at ${'x'.repeat(HOOK_FAILURE_STACK_FRAME_LIMIT + 40)}`,
|
|
81
|
+
...Array.from({ length: HOOK_FAILURE_STACK_FRAMES + 3 }, (_, index) => ` at frame${index} (file:///throughline/src/a.mjs:${index}:1)`),
|
|
82
|
+
].join('\n');
|
|
83
|
+
logHookFailure('HOOK_SESSION_START_FAILED', err, { home });
|
|
84
|
+
const noFrames = new Error('no frames');
|
|
85
|
+
noFrames.stack = 'Error: no frames';
|
|
86
|
+
logHookFailure('HOOK_SESSION_START_FAILED', noFrames, { home });
|
|
87
|
+
const [entry, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
88
|
+
assert.equal(entry.stack.length, HOOK_FAILURE_STACK_FRAMES);
|
|
89
|
+
assert.equal(entry.stack[0], 'at getDb (file:///throughline/src/db.mjs:455:5)');
|
|
90
|
+
assert.equal(entry.stack[1].length, HOOK_FAILURE_STACK_FRAME_LIMIT);
|
|
91
|
+
assert.equal(entry.stack[2], 'at frame0 (file:///throughline/src/a.mjs:0:1)');
|
|
92
|
+
assert.equal('stack' in second, false);
|
|
93
|
+
} finally {
|
|
94
|
+
rmSync(home, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
64
98
|
test('logHookFailureは、長い文面を上限で切る', () => {
|
|
65
99
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
66
100
|
try {
|
|
@@ -25,6 +25,16 @@ test('parseHookPayload accepts one leading BOM and preserves BOM inside prompt t
|
|
|
25
25
|
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), SyntaxError);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test('parseHookPayloadは、stdinが空の時と途中で切れた時を、文面の字数で見分けられるようにする', () => {
|
|
29
|
+
assert.throws(() => parseHookPayload(''), (error) => {
|
|
30
|
+
assert.equal(error instanceof SyntaxError, true);
|
|
31
|
+
assert.match(error.message, /^hook stdin is not valid JSON \(0 chars\): /u);
|
|
32
|
+
assert.equal(error.cause instanceof SyntaxError, true);
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), /^SyntaxError: hook stdin is not valid JSON \(20 chars\): /u);
|
|
36
|
+
});
|
|
37
|
+
|
|
28
38
|
test('isCursorEnvelope detects Cursor events and cursor_version', () => {
|
|
29
39
|
assert.equal(
|
|
30
40
|
isCursorEnvelope({
|
package/src/hosts/index.mjs
CHANGED
|
@@ -29,7 +29,15 @@ const ADAPTERS = Object.freeze({
|
|
|
29
29
|
|
|
30
30
|
/** Parse hook stdin, including the leading UTF-8 BOM emitted by Windows Cursor. */
|
|
31
31
|
export function parseHookPayload(raw, options = {}) {
|
|
32
|
-
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// host が stdin を渡さずに閉じた時(0 字)と、途中で切れた時を、理由の文面で見分けられるようにする。
|
|
37
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
38
|
+
throw new SyntaxError(`hook stdin is not valid JSON (${raw.length} chars): ${detail}`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
return normalizeHookPayload(parsed, options);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/**
|
package/src/resume-context.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
5
|
* - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
|
|
6
|
+
* - claude-auto-handoff.mjs(自動圧縮の直後のSessionStartで同じ会話へ注入。ADR 0032)
|
|
6
7
|
*
|
|
7
8
|
* 設計 (docs/02_clear_auto_handoff_plan.md):
|
|
8
9
|
* - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
|
|
@@ -55,6 +56,28 @@ const RESUME_HEADER_TEMPLATE = (turnCount, handoffDisclosure) =>
|
|
|
55
56
|
|
|
56
57
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
57
58
|
|
|
59
|
+
const DEFAULT_ANCHOR_TITLE = '### 現在地 (直前のやりとり)';
|
|
60
|
+
|
|
61
|
+
// 自動圧縮の直後に、同じ会話へ注入する時のヘッダ (ADR 0032)。/clear の引き継ぎと違い、
|
|
62
|
+
// 作業の途中で、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
|
|
63
|
+
const COMPACT_HEADER =
|
|
64
|
+
`## Throughline: 自動圧縮後の継続用コンテキスト\n` +
|
|
65
|
+
`\n` +
|
|
66
|
+
`**読み方 (重要):**\n` +
|
|
67
|
+
`- この会話は、たった今 Claude Code の自動圧縮で要約に置き換わりました。下記は Throughline が` +
|
|
68
|
+
`圧縮の前に保存した **あなた自身とユーザーの会話の原文** です。\n` +
|
|
69
|
+
`- 圧縮の要約と下記の記録が食い違う時は、下記の記録(元のユーザーの依頼・合意)を正としてください。\n` +
|
|
70
|
+
`- 「現在地」のターンは **作業の途中** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
|
|
71
|
+
`- 完了済みの操作を繰り返さないでください。直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
|
|
72
|
+
'- **完了したターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
|
|
73
|
+
`(該当ターンの本文+詳細を stdout に返します)`;
|
|
74
|
+
|
|
75
|
+
const COMPACT_ANCHOR_TITLE = '### 現在地 (作業途中のターン)';
|
|
76
|
+
// 作業途中のターンは Stop の前で、まだ bodies に無い。依頼の原文を残せる場所はここだけなので、
|
|
77
|
+
// /clear のアンカー (600 字) より厚く取る。
|
|
78
|
+
const IN_FLIGHT_USER_MAX_CHARS = 4_000;
|
|
79
|
+
const IN_FLIGHT_ASSISTANT_MAX_CHARS = 1_500;
|
|
80
|
+
|
|
58
81
|
// v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
|
|
59
82
|
// 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
|
|
60
83
|
// userが同じ文を引用した場合は会話内容なので保持する。
|
|
@@ -193,14 +216,14 @@ function buildResumeSections(
|
|
|
193
216
|
});
|
|
194
217
|
}
|
|
195
218
|
|
|
196
|
-
return { header, anchorLines, l1Lines, l2Lines };
|
|
219
|
+
return { header, anchorTitle: DEFAULT_ANCHOR_TITLE, anchorLines, l1Lines, l2Lines };
|
|
197
220
|
}
|
|
198
221
|
|
|
199
|
-
function joinSections({ header, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
222
|
+
function joinSections({ header, anchorTitle, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
200
223
|
const lines = [header];
|
|
201
224
|
if (anchorLines.length > 0) {
|
|
202
225
|
lines.push('');
|
|
203
|
-
lines.push(
|
|
226
|
+
lines.push(anchorTitle);
|
|
204
227
|
lines.push(...anchorLines);
|
|
205
228
|
}
|
|
206
229
|
if (l1Lines.length > 0 || l1Note) {
|
|
@@ -396,7 +419,11 @@ export function buildBudgetedResumeContext(
|
|
|
396
419
|
handoffDisclosure,
|
|
397
420
|
});
|
|
398
421
|
if (!sections) return null;
|
|
422
|
+
return renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars });
|
|
423
|
+
}
|
|
399
424
|
|
|
425
|
+
/** 組み立て済みのセクションを、予算内の注入テキストにする(/clear の引き継ぎと自動圧縮後の継続が共有)。 */
|
|
426
|
+
function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars }) {
|
|
400
427
|
const lineCost = (s) => s.length + 1; // join('\n') 分
|
|
401
428
|
|
|
402
429
|
// L2 行をターン単位のグループにまとめる(元の古い順を保つ)
|
|
@@ -418,7 +445,7 @@ export function buildBudgetedResumeContext(
|
|
|
418
445
|
const fixedCost =
|
|
419
446
|
lineCost(sections.header) +
|
|
420
447
|
(sections.anchorLines.length > 0
|
|
421
|
-
? lineCost('') + lineCost(
|
|
448
|
+
? lineCost('') + lineCost(sections.anchorTitle) +
|
|
422
449
|
sections.anchorLines.reduce((a, l) => a + lineCost(l), 0)
|
|
423
450
|
: 0) +
|
|
424
451
|
lineCost('') + lineCost('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
@@ -487,7 +514,7 @@ export function buildBudgetedResumeContext(
|
|
|
487
514
|
const lines = [sections.header];
|
|
488
515
|
if (sections.anchorLines.length > 0) {
|
|
489
516
|
lines.push('');
|
|
490
|
-
lines.push(
|
|
517
|
+
lines.push(sections.anchorTitle);
|
|
491
518
|
lines.push(...sections.anchorLines);
|
|
492
519
|
}
|
|
493
520
|
lines.push('');
|
|
@@ -511,3 +538,72 @@ export function buildBudgetedResumeContext(
|
|
|
511
538
|
truncatedNewestL2,
|
|
512
539
|
};
|
|
513
540
|
}
|
|
541
|
+
|
|
542
|
+
// 長い依頼は、指示が先頭にも末尾にも書かれる。先頭 3/4 と末尾 1/4 を残し、間を抜いたことを明示する。
|
|
543
|
+
function truncateInFlight(text, maxChars) {
|
|
544
|
+
const trimmed = text.trim();
|
|
545
|
+
if (trimmed.length <= maxChars) return trimmed;
|
|
546
|
+
const head = Math.ceil(maxChars * 0.75);
|
|
547
|
+
const tail = maxChars - head;
|
|
548
|
+
return `${trimmed.slice(0, head)} …(長いため中略 ${trimmed.length - maxChars} 字)… ${trimmed.slice(-tail)}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 自動圧縮の直後に、同じ会話へ注入するテキスト (ADR 0032)。
|
|
553
|
+
*
|
|
554
|
+
* 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
|
|
555
|
+
* DB の最新ターンではなく、transcript から読んだ作業途中のターンを載せる。作業途中のターンは
|
|
556
|
+
* Stop の前で bodies に無いので、L2 セクションには出ない。
|
|
557
|
+
*
|
|
558
|
+
* 完了したターンが 1 つも無い会話(最初のターンの途中で圧縮)でも、現在地だけを返す。
|
|
559
|
+
*
|
|
560
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
561
|
+
* @param {{
|
|
562
|
+
* sessionId: string,
|
|
563
|
+
* inFlight: {
|
|
564
|
+
* user: {content: string, timestamp: number|null},
|
|
565
|
+
* fragments: Array<{content: string, timestamp: number|null}>,
|
|
566
|
+
* } | null,
|
|
567
|
+
* inFlightUnreadable?: boolean, transcript に今の依頼がまだ書かれていない(hook の prompt_id と合わない)
|
|
568
|
+
* maxChars?: number,
|
|
569
|
+
* }} params
|
|
570
|
+
*/
|
|
571
|
+
export function buildCompactContinuationContext(
|
|
572
|
+
db,
|
|
573
|
+
{ sessionId, inFlight, inFlightUnreadable = false, maxChars = INJECTION_BUDGET_CHARS },
|
|
574
|
+
) {
|
|
575
|
+
const stored = buildResumeSections(db, { sessionId, isInheritance: true, handoffDisclosure: 'silent' });
|
|
576
|
+
const stamp = (timestamp) => (timestamp == null ? '' : ` [${formatClock(timestamp)}]`);
|
|
577
|
+
const anchorLines = [];
|
|
578
|
+
if (inFlight) {
|
|
579
|
+
anchorLines.push(
|
|
580
|
+
`**作業中のユーザー依頼**${stamp(inFlight.user.timestamp)}: ` +
|
|
581
|
+
truncateInFlight(inFlight.user.content, IN_FLIGHT_USER_MAX_CHARS),
|
|
582
|
+
);
|
|
583
|
+
const lastFragment = inFlight.fragments.at(-1);
|
|
584
|
+
if (lastFragment) {
|
|
585
|
+
anchorLines.push(
|
|
586
|
+
`**圧縮直前のあなたの発言**${stamp(lastFragment.timestamp)}: ` +
|
|
587
|
+
truncateInFlight(lastFragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!inFlight && inFlightUnreadable) {
|
|
592
|
+
anchorLines.push(
|
|
593
|
+
'**作業中のユーザー依頼**: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)',
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (!stored && anchorLines.length === 0) return null;
|
|
597
|
+
|
|
598
|
+
return renderBudgetedSections(
|
|
599
|
+
db,
|
|
600
|
+
{
|
|
601
|
+
header: COMPACT_HEADER,
|
|
602
|
+
anchorTitle: COMPACT_ANCHOR_TITLE,
|
|
603
|
+
anchorLines,
|
|
604
|
+
l1Lines: [],
|
|
605
|
+
l2Lines: stored?.l2Lines ?? [],
|
|
606
|
+
},
|
|
607
|
+
{ sessionId, excludeOriginId: null, maxChars },
|
|
608
|
+
);
|
|
609
|
+
}
|
|
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
4
4
|
import {
|
|
5
5
|
buildResumeContext,
|
|
6
6
|
buildBudgetedResumeContext,
|
|
7
|
+
buildCompactContinuationContext,
|
|
7
8
|
INJECTION_BUDGET_CHARS,
|
|
8
9
|
} from './resume-context.mjs';
|
|
9
10
|
|
|
@@ -905,3 +906,74 @@ test('budgeted: header and anchor always survive even under pressure', () => {
|
|
|
905
906
|
assert.match(budgeted.text, /^## Throughline: 直前スレッドの継続応答用コンテキスト/);
|
|
906
907
|
assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
|
|
907
908
|
});
|
|
909
|
+
|
|
910
|
+
// --- 自動圧縮後の継続 (ADR 0032) ---
|
|
911
|
+
|
|
912
|
+
test('buildCompactContinuationContext: 完了したターンが無くても、作業途中のターンだけで組み立てる', () => {
|
|
913
|
+
const db = makeDb();
|
|
914
|
+
const result = buildCompactContinuationContext(db, {
|
|
915
|
+
sessionId: 'S',
|
|
916
|
+
inFlight: { user: { content: '最初の依頼', timestamp: null }, fragments: [] },
|
|
917
|
+
});
|
|
918
|
+
assert.match(result.text, /^## Throughline: 自動圧縮後の継続用コンテキスト\n/);
|
|
919
|
+
assert.match(result.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: 最初の依頼\n/);
|
|
920
|
+
assert.doesNotMatch(result.text, /圧縮直前のあなたの発言/);
|
|
921
|
+
assert.doesNotMatch(result.text, /### 直前の対話/);
|
|
922
|
+
assert.equal(result.injectedL2Turns, 0);
|
|
923
|
+
assert.equal(buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null }), null);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('buildCompactContinuationContext: 現在地は作業途中のターンで、保存済みのターンはL2に並び、宣言は求めない', () => {
|
|
927
|
+
const db = makeDb();
|
|
928
|
+
insertBody(db, { session: 'S', origin: 'S', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
|
|
929
|
+
insertBody(db, { session: 'S', origin: 'S', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
|
|
930
|
+
const result = buildCompactContinuationContext(db, {
|
|
931
|
+
sessionId: 'S',
|
|
932
|
+
inFlight: {
|
|
933
|
+
user: { content: '今の依頼', timestamp: 3_000 },
|
|
934
|
+
fragments: [{ content: '途中1', timestamp: 4_000 }, { content: '途中2', timestamp: 5_000 }],
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
|
|
938
|
+
assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n\*\*圧縮直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 途中2\n/);
|
|
939
|
+
assert.doesNotMatch(anchor, /前の依頼|途中1/);
|
|
940
|
+
assert.match(result.text, /### 直前の対話 \(L2 \/ active work thread, 古い順\)\n\[\d\d:\d\d:\d\d\] \[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
|
|
941
|
+
assert.doesNotMatch(result.text, /宣言|\/clear/);
|
|
942
|
+
assert.equal(result.injectedL2Turns, 1);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('buildCompactContinuationContext: 長い依頼と多数のターンでも予算内に収め、依頼は切り詰めを明示する', () => {
|
|
946
|
+
const db = makeDb();
|
|
947
|
+
for (let turn = 1; turn <= 12; turn++) {
|
|
948
|
+
insertBody(db, { session: 'S', origin: 'S', turn, role: 'user', text: `依頼${turn} ` + 'あ'.repeat(600), createdAt: turn * 1_000 });
|
|
949
|
+
insertBody(db, { session: 'S', origin: 'S', turn, role: 'assistant', text: `回答${turn} ` + 'い'.repeat(600), createdAt: turn * 1_000 + 500 });
|
|
950
|
+
}
|
|
951
|
+
const result = buildCompactContinuationContext(db, {
|
|
952
|
+
sessionId: 'S',
|
|
953
|
+
inFlight: {
|
|
954
|
+
user: { content: 'ヰ'.repeat(6_000), timestamp: 20_000 },
|
|
955
|
+
fragments: [{ content: 'ヱ'.repeat(3_000), timestamp: 21_000 }],
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
|
|
959
|
+
assert.equal((result.text.match(/ヰ/g) ?? []).length, 4_000);
|
|
960
|
+
assert.equal((result.text.match(/ヱ/g) ?? []).length, 1_500);
|
|
961
|
+
assert.match(result.text, /ヰ …\(長いため中略 2000 字\)… ヰ/);
|
|
962
|
+
assert.match(result.text, /ヱ …\(長いため中略 1500 字\)… ヱ/);
|
|
963
|
+
assert.ok(result.injectedL2Turns >= 1, '最新の保存済みターンは入る');
|
|
964
|
+
assert.match(result.text, /回答12/);
|
|
965
|
+
assert.equal(result.injectedL2Turns + result.remainingL2Turns, 12);
|
|
966
|
+
assert.match(result.text, /throughline recall --l2 --session S /);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test('buildCompactContinuationContext: 長い依頼は先頭と末尾を残す。今の依頼が記録から読めない時は、その旨だけを現在地に書く', () => {
|
|
970
|
+
const db = makeDb();
|
|
971
|
+
const long = buildCompactContinuationContext(db, {
|
|
972
|
+
sessionId: 'S',
|
|
973
|
+
inFlight: { user: { content: '先頭の指示' + 'あ'.repeat(6_000) + '末尾の指示', timestamp: null }, fragments: [] },
|
|
974
|
+
});
|
|
975
|
+
assert.match(long.text, /\*\*作業中のユーザー依頼\*\*: 先頭の指示あ+ …\(長いため中略 2010 字\)… あ+末尾の指示\n/);
|
|
976
|
+
|
|
977
|
+
const unreadable = buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null, inFlightUnreadable: true });
|
|
978
|
+
assert.match(unreadable.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)\n/);
|
|
979
|
+
});
|
|
@@ -47,6 +47,12 @@ const DEFINITIONS = Object.freeze({
|
|
|
47
47
|
severity: 'high',
|
|
48
48
|
template: 'Throughline Claude Stop hook processing failed',
|
|
49
49
|
}),
|
|
50
|
+
// 自動継続の印を残せなかった。圧縮と作業は続く(ADR 0032)。
|
|
51
|
+
HOOK_PRE_COMPACT_FAILED: Object.freeze({
|
|
52
|
+
component: 'claude_pre_compact_hook',
|
|
53
|
+
severity: 'warn',
|
|
54
|
+
template: 'Throughline Claude PreCompact hook processing failed',
|
|
55
|
+
}),
|
|
50
56
|
HOOK_CODEX_FAILED: Object.freeze({
|
|
51
57
|
component: 'codex_hook',
|
|
52
58
|
severity: 'high',
|
package/src/session-start.mjs
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
*
|
|
22
22
|
* バトンの消費・merge・注入は最初の UserPromptSubmit (= 実体の証明) で行う。
|
|
23
23
|
* 幽霊はプロンプトを発火しないため記憶を奪えない。
|
|
24
|
+
*
|
|
25
|
+
* 【自動圧縮後の継続】 ADR 0032
|
|
26
|
+
*
|
|
27
|
+
* source='compact' は新しい会話ではなく、圧縮を終えた同じ会話。PreCompact hook が
|
|
28
|
+
* 自動圧縮の印を残していた時だけ、作業途中のターンと直近の会話の原文を stdout へ書く。
|
|
29
|
+
* host がそのまま作業を続けるので、次の UserPromptSubmit は待たない。
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
import { getDb } from './db.mjs';
|
|
@@ -33,8 +39,9 @@ import { existsSync } from 'node:fs';
|
|
|
33
39
|
import { pathToFileURL } from 'node:url';
|
|
34
40
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
35
41
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
36
|
-
import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
42
|
+
import { CLAUDE_HOST, hostAdapterForSessionId, hostOfSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
37
43
|
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
44
|
+
import { consumeClaudeCompactRequest, buildClaudeCompactContinuation } from './claude-auto-handoff.mjs';
|
|
38
45
|
|
|
39
46
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
40
47
|
|
|
@@ -163,6 +170,31 @@ export async function run() {
|
|
|
163
170
|
pending_registered: true,
|
|
164
171
|
});
|
|
165
172
|
|
|
173
|
+
// 自動圧縮の直後: 印があれば、圧縮前の記録を同じ会話へ注入する (ADR 0032)。
|
|
174
|
+
// subagent の中で走った hook は、親の会話の印を消費しない。
|
|
175
|
+
if (source === 'compact' && hostOfSessionId(session_id) === CLAUDE_HOST && !payload.agent_id) {
|
|
176
|
+
const request = consumeClaudeCompactRequest({ sessionId: session_id });
|
|
177
|
+
if (request) {
|
|
178
|
+
const continuation = buildClaudeCompactContinuation(db, {
|
|
179
|
+
sessionId: session_id,
|
|
180
|
+
transcriptPath: transcript_path ?? request.transcript_path,
|
|
181
|
+
promptId: payload.prompt_id ?? null,
|
|
182
|
+
now,
|
|
183
|
+
});
|
|
184
|
+
if (continuation) process.stdout.write(continuation.text + '\n');
|
|
185
|
+
logDecision({
|
|
186
|
+
ts: new Date(now).toISOString(),
|
|
187
|
+
phase: 'compact-continuation',
|
|
188
|
+
session_id,
|
|
189
|
+
project_path: request.project_path,
|
|
190
|
+
transcript_path: transcript_path ?? request.transcript_path ?? null,
|
|
191
|
+
requested_at: new Date(request.requested_at).toISOString(),
|
|
192
|
+
injected: Boolean(continuation),
|
|
193
|
+
...(continuation?.stats ?? {}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
// Cursor は beforeSubmitPrompt が additional_context を持たない。
|
|
167
199
|
// 新規 composer 会話の sessionStart が公式の注入口なので、ここで第二相を実行する。
|
|
168
200
|
// Claude の幽霊 SessionStart 問題はこの host では sessionStart = 会話作成のため適用しない。
|