throughline 0.6.3 → 0.8.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +101 -1
  2. package/README.md +86 -28
  3. package/bin/throughline.mjs +20 -0
  4. package/docs/00_overview.md +12 -0
  5. package/docs/02_clear_auto_handoff_plan.md +47 -13
  6. package/docs/04_public_release_plan.md +1 -0
  7. package/docs/14_observer_completed_turn_feed_plan.md +290 -0
  8. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
  9. package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
  10. package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
  11. package/docs/adr/0004-observer-db-pair-projection.md +71 -0
  12. package/docs/adr/0005-observer-read-pagination.md +51 -0
  13. package/docs/adr/0006-observer-page-offset-proof.md +33 -0
  14. package/docs/adr/0007-observer-read-cli-contract.md +61 -0
  15. package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
  16. package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
  17. package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
  18. package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
  19. package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
  20. package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
  21. package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
  22. package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
  23. package/docs/adr/0016-push-pull-recall-injection.md +93 -0
  24. package/package.json +1 -1
  25. package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -0
  26. package/rag/INDEX.md +4 -0
  27. package/src/auditor-context.mjs +92 -11
  28. package/src/auditor-context.test.mjs +116 -1
  29. package/src/baton.mjs +27 -7
  30. package/src/baton.test.mjs +44 -0
  31. package/src/body-digest.mjs +9 -0
  32. package/src/cli/auditor-context.test.mjs +1 -1
  33. package/src/cli/factory-diagnostics.mjs +1 -0
  34. package/src/cli/factory-diagnostics.test.mjs +6 -2
  35. package/src/cli/observer-read.mjs +73 -0
  36. package/src/cli/observer-read.test.mjs +93 -0
  37. package/src/cli/observer-wait.mjs +123 -0
  38. package/src/cli/observer-wait.test.mjs +167 -0
  39. package/src/cli/recall.mjs +279 -0
  40. package/src/cli/recall.test.mjs +269 -0
  41. package/src/codex-rollout-memory.mjs +13 -0
  42. package/src/codex-rollout-memory.test.mjs +27 -0
  43. package/src/codex-thread-index.mjs +1 -1
  44. package/src/codex-thread-index.test.mjs +18 -0
  45. package/src/completed-turn-receipts.mjs +374 -0
  46. package/src/completed-turn-receipts.test.mjs +186 -0
  47. package/src/db-schema.test.mjs +9 -2
  48. package/src/db.mjs +20 -1
  49. package/src/decision-log.mjs +24 -0
  50. package/src/haiku-summarizer.mjs +93 -16
  51. package/src/haiku-summarizer.test.mjs +118 -9
  52. package/src/handoff-executor.mjs +161 -0
  53. package/src/hook-entrypoints.test.mjs +192 -12
  54. package/src/observer-codex-projection.test.mjs +49 -0
  55. package/src/observer-turn-feed.mjs +392 -0
  56. package/src/observer-turn-feed.test.mjs +339 -0
  57. package/src/observer-turn-wait.mjs +102 -0
  58. package/src/observer-turn-wait.test.mjs +122 -0
  59. package/src/pending-handoff.mjs +96 -0
  60. package/src/pending-handoff.test.mjs +107 -0
  61. package/src/prompt-submit.mjs +46 -1
  62. package/src/resume-context.mjs +337 -60
  63. package/src/resume-context.test.mjs +228 -1
  64. package/src/runtime-error-store.mjs +2 -1
  65. package/src/runtime-error-store.test.mjs +1 -1
  66. package/src/session-start.mjs +70 -233
  67. package/src/transcript-reader.mjs +32 -0
  68. package/src/turn-backfill.mjs +3 -2
  69. package/src/turn-backfill.test.mjs +10 -4
  70. package/src/turn-processor.mjs +90 -1
  71. package/src/turn-processor.test.mjs +142 -0
  72. package/src/windows-acl-test-helper.mjs +2 -2
@@ -0,0 +1,374 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import {
3
+ chmodSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import childProcess from 'node:child_process';
12
+ import { homedir, platform as hostPlatform } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { DatabaseSync } from 'node:sqlite';
15
+ import { hashAuditorBody } from './body-digest.mjs';
16
+ import { normalizeProjectPathForCompare } from './project-path.mjs';
17
+
18
+ export const COMPLETED_TURN_RECEIPT_STORE_SCHEMA = 'throughline.completed_turn_receipts.v1';
19
+ export const COMPLETED_TURN_RECEIPT_SCHEMA_VERSION = '1.0';
20
+ export const COMPLETED_TURN_RECEIPT_LIMIT = 256;
21
+
22
+ const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.completed-turn-receipt-directory');
23
+ // CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
24
+ const WINDOWS_ACL_TIMEOUT_MS = 15_000;
25
+
26
+ export function defaultCompletedTurnReceiptStorePath(projectSha256, env = process.env) {
27
+ if (!isSha256(projectSha256)) throw new TypeError('projectSha256 must be a SHA-256 digest');
28
+ const home = env.HOME || env.USERPROFILE || homedir();
29
+ if (isWindows(env)) {
30
+ return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
31
+ }
32
+ return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'throughline', 'completed-turn-receipts', `${projectSha256}.json`);
33
+ }
34
+
35
+ /**
36
+ * Captures one Claude Stop completion after its L2 user/assistant pair is durable in SQLite.
37
+ * The pair identity is immutable: a Stop retry returns the original receipt and never consumes
38
+ * another sequence number.
39
+ */
40
+ export function writeCompletedTurnReceipt(input, options = {}) {
41
+ assertExactInput(input);
42
+ assertExactOptions(options);
43
+ const normalized = normalizeInput(input);
44
+ return withStoreLock(normalized, options, (privateDirectory) => {
45
+ const store = readStore(normalized, options, privateDirectory);
46
+ const existing = store.receipts.find((receipt) => samePair(receipt, normalized));
47
+ if (existing) return { ...existing };
48
+
49
+ const receipt = {
50
+ schema_version: COMPLETED_TURN_RECEIPT_SCHEMA_VERSION,
51
+ host: 'claude',
52
+ project_sha256: normalized.projectSha256,
53
+ target_session_id: normalized.targetSessionId,
54
+ origin_session_id: normalized.originSessionId,
55
+ user_sha256: normalized.userSha256,
56
+ assistant_sha256: normalized.assistantSha256,
57
+ completed_at: normalized.completedAt,
58
+ sequence: store.next_sequence,
59
+ };
60
+ store.next_sequence += 1;
61
+ store.receipts.push(receipt);
62
+ if (store.receipts.length > COMPLETED_TURN_RECEIPT_LIMIT) {
63
+ store.receipts.splice(0, store.receipts.length - COMPLETED_TURN_RECEIPT_LIMIT);
64
+ store.history_floor = store.receipts[0].sequence;
65
+ }
66
+ writeStore(store, normalized, options, privateDirectory);
67
+ return { ...receipt };
68
+ });
69
+ }
70
+
71
+ /** Read-only receipt snapshot. This never creates a directory, lock, or store file. */
72
+ export function readCompletedTurnReceiptSnapshot(input = {}) {
73
+ if (!input || typeof input !== 'object' || Array.isArray(input) ||
74
+ Object.keys(input).some((key) => !['projectPath', 'env', 'storePath'].includes(key))) {
75
+ throw new TypeError('completed turn receipt snapshot options are invalid');
76
+ }
77
+ const { projectPath, env = process.env, storePath } = input;
78
+ const projectSha256 = sha256(normalizeProjectPathForCompare(assertProjectPath(projectPath)));
79
+ const options = { env, storePath };
80
+ const path = storePathFor({ projectSha256 }, options);
81
+ try {
82
+ const info = lstatSync(path);
83
+ assertPrivateFile(info, env, path);
84
+ assertPrivateDirectory(dirname(path), env);
85
+ const store = JSON.parse(readFileSync(path, 'utf8'));
86
+ validateStore(store, projectSha256);
87
+ return cloneStore(store);
88
+ } catch (error) {
89
+ if (error?.code === 'ENOENT') return emptyStore(projectSha256);
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ function normalizeInput(input) {
95
+ return {
96
+ projectSha256: sha256(normalizeProjectPathForCompare(assertProjectPath(input.projectPath))),
97
+ targetSessionId: normalizeIdentity(input.targetSessionId, 'targetSessionId'),
98
+ originSessionId: normalizeIdentity(input.originSessionId, 'originSessionId'),
99
+ userSha256: hashAuditorBody(assertBody(input.userBody)),
100
+ assistantSha256: hashAuditorBody(assertBody(input.assistantBody)),
101
+ completedAt: normalizeCompletionTimestamp(input.completedAt),
102
+ };
103
+ }
104
+
105
+ function emptyStore(projectSha256) {
106
+ return {
107
+ schema: COMPLETED_TURN_RECEIPT_STORE_SCHEMA,
108
+ project_sha256: projectSha256,
109
+ next_sequence: 1,
110
+ history_floor: 1,
111
+ receipts: [],
112
+ };
113
+ }
114
+
115
+ function readStore(normalized, options, privateDirectory) {
116
+ const path = storePathFor(normalized, options);
117
+ try {
118
+ const info = lstatSync(path);
119
+ assertPrivateFile(info, options.env, path);
120
+ assertPrivateDirectoryCapability(privateDirectory, dirname(path), options.env);
121
+ const store = JSON.parse(readFileSync(path, 'utf8'));
122
+ validateStore(store, normalized.projectSha256);
123
+ return store;
124
+ } catch (error) {
125
+ if (error?.code === 'ENOENT') return emptyStore(normalized.projectSha256);
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ function writeStore(store, normalized, options, privateDirectory) {
131
+ validateStore(store, normalized.projectSha256);
132
+ const path = storePathFor(normalized, options);
133
+ const directory = dirname(path);
134
+ assertPrivateDirectoryCapability(privateDirectory, directory, options.env);
135
+ const temporary = join(directory, `.completed-turn-receipts.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
136
+ try {
137
+ writeFileSync(temporary, `${JSON.stringify(store)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
138
+ if (isWindows(options.env)) {
139
+ applyAndVerifyWindowsAcl(temporary, false);
140
+ assertPrivateFileShape(lstatSync(temporary));
141
+ } else {
142
+ chmodSync(temporary, 0o600);
143
+ }
144
+ renameSync(temporary, path);
145
+ assertPrivateFile(lstatSync(path), options.env, path);
146
+ } finally {
147
+ rmSync(temporary, { force: true });
148
+ }
149
+ }
150
+
151
+ function withStoreLock(normalized, options, operation) {
152
+ const storePath = storePathFor(normalized, options);
153
+ const directory = dirname(storePath);
154
+ ensurePrivateDirectory(directory, options.env);
155
+ const lockPath = `${storePath}.lock.sqlite`;
156
+ let created = false;
157
+ try {
158
+ writeFileSync(lockPath, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
159
+ created = true;
160
+ } catch (error) {
161
+ if (error?.code !== 'EEXIST') throw error;
162
+ }
163
+ if (created) {
164
+ if (isWindows(options.env)) applyAndVerifyWindowsAcl(lockPath, false);
165
+ else chmodSync(lockPath, 0o600);
166
+ }
167
+ assertPrivateFile(lstatSync(lockPath), options.env, lockPath);
168
+ const database = new DatabaseSync(lockPath);
169
+ let active = false;
170
+ try {
171
+ database.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL; BEGIN IMMEDIATE');
172
+ active = true;
173
+ const result = operation(privateDirectoryCapability(directory, options.env));
174
+ database.exec('COMMIT');
175
+ active = false;
176
+ return result;
177
+ } finally {
178
+ if (active) { try { database.exec('ROLLBACK'); } catch {} }
179
+ database.close();
180
+ }
181
+ }
182
+
183
+ function ensurePrivateDirectory(directory, env) {
184
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
185
+ const info = lstatSync(directory);
186
+ assertPrivateDirectoryShape(info);
187
+ if (isWindows(env)) applyAndVerifyWindowsAcl(directory, true);
188
+ else chmodSync(directory, 0o700);
189
+ assertPrivateDirectory(directory, env);
190
+ }
191
+
192
+ function privateDirectoryCapability(directory, env) {
193
+ return Object.freeze({ directory, windows: isWindows(env), [PRIVATE_DIRECTORY_CAPABILITY]: true });
194
+ }
195
+
196
+ function assertPrivateDirectoryCapability(capability, directory, env) {
197
+ if (!capability || capability[PRIVATE_DIRECTORY_CAPABILITY] !== true ||
198
+ capability.directory !== directory || capability.windows !== isWindows(env)) {
199
+ assertPrivateDirectory(directory, env);
200
+ return;
201
+ }
202
+ assertPrivateDirectoryShape(lstatSync(directory));
203
+ }
204
+
205
+ function assertPrivateDirectory(directory, env) {
206
+ const info = lstatSync(directory);
207
+ assertPrivateDirectoryShape(info);
208
+ if (isWindows(env)) verifyWindowsAcl(directory, true);
209
+ else assertPosixOwnerMode(info, 0o700);
210
+ }
211
+
212
+ function assertPrivateFile(info, env, path) {
213
+ assertPrivateFileShape(info);
214
+ if (isWindows(env)) verifyWindowsAcl(path, false);
215
+ else assertPosixOwnerMode(info, 0o600);
216
+ }
217
+
218
+ function assertPrivateDirectoryShape(info) {
219
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('completed turn receipt directory unsafe');
220
+ }
221
+
222
+ function assertPrivateFileShape(info) {
223
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('completed turn receipt store path unsafe');
224
+ }
225
+
226
+ function assertPosixOwnerMode(info, expectedMode) {
227
+ if ((info.mode & 0o777) !== expectedMode) throw new Error('completed turn receipt permissions unsafe');
228
+ if (typeof process.getuid === 'function' && info.uid !== process.getuid()) {
229
+ throw new Error('completed turn receipt owner unsafe');
230
+ }
231
+ }
232
+
233
+ function validateStore(store, expectedProjectSha256) {
234
+ if (!store || typeof store !== 'object' || Array.isArray(store) ||
235
+ !exactKeys(store, ['schema', 'project_sha256', 'next_sequence', 'history_floor', 'receipts']) ||
236
+ store.schema !== COMPLETED_TURN_RECEIPT_STORE_SCHEMA ||
237
+ store.project_sha256 !== expectedProjectSha256 || !isSha256(store.project_sha256) ||
238
+ !Number.isSafeInteger(store.next_sequence) || store.next_sequence < 1 ||
239
+ !Number.isSafeInteger(store.history_floor) || store.history_floor < 1 || store.history_floor > store.next_sequence ||
240
+ !Array.isArray(store.receipts) || store.receipts.length > COMPLETED_TURN_RECEIPT_LIMIT) {
241
+ throw new Error('completed turn receipt store schema invalid');
242
+ }
243
+ const identities = new Set();
244
+ for (const [index, receipt] of store.receipts.entries()) {
245
+ validateReceipt(receipt, store.next_sequence);
246
+ const identity = receiptIdentity(receipt);
247
+ if (identities.has(identity) || receipt.project_sha256 !== store.project_sha256 ||
248
+ receipt.sequence !== store.history_floor + index) {
249
+ throw new Error('completed turn receipt uniqueness invalid');
250
+ }
251
+ identities.add(identity);
252
+ }
253
+ if (store.receipts.length > 0 && store.history_floor !== store.receipts[0].sequence) {
254
+ throw new Error('completed turn receipt history floor invalid');
255
+ }
256
+ if (store.receipts.length === 0 && store.history_floor !== store.next_sequence) {
257
+ throw new Error('completed turn receipt history floor invalid');
258
+ }
259
+ if (store.receipts.length > 0 && store.next_sequence !== store.history_floor + store.receipts.length) {
260
+ throw new Error('completed turn receipt sequence continuity invalid');
261
+ }
262
+ }
263
+
264
+ function validateReceipt(receipt, nextSequence) {
265
+ const keys = [
266
+ 'schema_version', 'host', 'project_sha256', 'target_session_id', 'origin_session_id',
267
+ 'user_sha256', 'assistant_sha256', 'completed_at', 'sequence',
268
+ ];
269
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) ||
270
+ !exactKeys(receipt, keys) || receipt.schema_version !== COMPLETED_TURN_RECEIPT_SCHEMA_VERSION ||
271
+ receipt.host !== 'claude' || !isSha256(receipt.project_sha256) ||
272
+ !isIdentity(receipt.target_session_id) || !isIdentity(receipt.origin_session_id) ||
273
+ !isSha256(receipt.user_sha256) || !isSha256(receipt.assistant_sha256) ||
274
+ !Number.isSafeInteger(receipt.completed_at) || receipt.completed_at < 0 ||
275
+ !Number.isSafeInteger(receipt.sequence) || receipt.sequence < 1 || receipt.sequence >= nextSequence) {
276
+ throw new Error('completed turn receipt value invalid');
277
+ }
278
+ }
279
+
280
+ function samePair(receipt, input) {
281
+ return receipt.project_sha256 === input.projectSha256 &&
282
+ receipt.origin_session_id === input.originSessionId &&
283
+ receipt.user_sha256 === input.userSha256 &&
284
+ receipt.assistant_sha256 === input.assistantSha256;
285
+ }
286
+
287
+ function receiptIdentity(receipt) {
288
+ return [receipt.project_sha256, receipt.origin_session_id,
289
+ receipt.user_sha256, receipt.assistant_sha256].join('\0');
290
+ }
291
+
292
+ function assertBody(value) {
293
+ if (typeof value !== 'string') throw new TypeError('receipt body must be a string');
294
+ return value;
295
+ }
296
+
297
+ function normalizeIdentity(value, name) {
298
+ if (!isIdentity(value)) throw new TypeError(`${name} must be a non-empty string`);
299
+ return value;
300
+ }
301
+
302
+ function normalizeCompletionTimestamp(value) {
303
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError('completedAt must be a non-negative integer');
304
+ return value;
305
+ }
306
+
307
+ function assertProjectPath(value) {
308
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError('projectPath must be a non-empty string');
309
+ return value;
310
+ }
311
+
312
+ function storePathFor(normalized, options) {
313
+ return options.storePath || defaultCompletedTurnReceiptStorePath(normalized.projectSha256, options.env);
314
+ }
315
+
316
+ function cloneStore(store) {
317
+ return {
318
+ schema: store.schema,
319
+ project_sha256: store.project_sha256,
320
+ next_sequence: store.next_sequence,
321
+ history_floor: store.history_floor,
322
+ receipts: store.receipts.map((receipt) => ({ ...receipt })),
323
+ };
324
+ }
325
+
326
+ function sha256(value) {
327
+ return createHash('sha256').update(value, 'utf8').digest('hex');
328
+ }
329
+
330
+ function isSha256(value) { return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); }
331
+ function isIdentity(value) { return typeof value === 'string' && value.length > 0 && value.length <= 4096; }
332
+ function exactKeys(value, keys) {
333
+ const actual = Object.keys(value);
334
+ return actual.length === keys.length && keys.every((key) => actual.includes(key));
335
+ }
336
+ function assertExactInput(input) {
337
+ if (!input || typeof input !== 'object' || Array.isArray(input) ||
338
+ !exactKeys(input, ['projectPath', 'targetSessionId', 'originSessionId', 'userBody', 'assistantBody', 'completedAt'])) {
339
+ throw new TypeError('completed turn receipt input is invalid');
340
+ }
341
+ }
342
+ function assertExactOptions(options) {
343
+ if (!options || typeof options !== 'object' || Array.isArray(options) ||
344
+ Object.keys(options).some((key) => !['env', 'storePath'].includes(key))) {
345
+ throw new TypeError('completed turn receipt options are invalid');
346
+ }
347
+ }
348
+ function isWindows(env = process.env) { return env.OS === 'Windows_NT' || hostPlatform() === 'win32'; }
349
+
350
+ function applyAndVerifyWindowsAcl(path, directory) { runWindowsAclScript(path, directory, WINDOWS_ACL_APPLY_SCRIPT); }
351
+ function verifyWindowsAcl(path, directory) { runWindowsAclScript(path, directory, WINDOWS_ACL_VERIFY_SCRIPT); }
352
+ function runWindowsAclScript(path, directory, script) {
353
+ const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
354
+ env: { ...process.env, THROUGHLINE_ACL_PATH: path, THROUGHLINE_ACL_DIRECTORY: directory ? '1' : '0' },
355
+ stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
356
+ });
357
+ if (result.status !== 0) throw new Error('Windows owner-only ACL verification failed');
358
+ }
359
+
360
+ const WINDOWS_ACL_VERIFY_SCRIPT = String.raw`
361
+ $p=$env:THROUGHLINE_ACL_PATH; $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
362
+ $acl=if($isDir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)}
363
+ $owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
364
+ if($owner -ne $sid){exit 41}; $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if($rules.Count -ne 1){exit 42}
365
+ $r=$rules[0]; if($r.IdentityReference.Value -ne $sid -or $r.AccessControlType -ne 'Allow' -or $r.IsInherited -or ($r.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
366
+ `;
367
+
368
+ const WINDOWS_ACL_APPLY_SCRIPT = String.raw`
369
+ $p=$env:THROUGHLINE_ACL_PATH; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
370
+ $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $acl=if($isDir){New-Object System.Security.AccessControl.DirectorySecurity}else{New-Object System.Security.AccessControl.FileSecurity}; $acl.SetAccessRuleProtection($true,$false)
371
+ $flags=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
372
+ $rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
373
+ $acl.SetOwner($sid); $acl.AddAccessRule($rule); if($isDir){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}
374
+ ` + WINDOWS_ACL_VERIFY_SCRIPT;
@@ -0,0 +1,186 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import {
7
+ COMPLETED_TURN_RECEIPT_LIMIT,
8
+ COMPLETED_TURN_RECEIPT_STORE_SCHEMA,
9
+ defaultCompletedTurnReceiptStorePath,
10
+ readCompletedTurnReceiptSnapshot,
11
+ writeCompletedTurnReceipt,
12
+ } from './completed-turn-receipts.mjs';
13
+
14
+ function sandbox() {
15
+ const root = mkdtempSync(join(tmpdir(), 'throughline-completed-receipts-'));
16
+ return { root, storePath: join(root, 'state', 'completed-turn-receipts.json') };
17
+ }
18
+
19
+ function input(index = 1) {
20
+ return {
21
+ projectPath: '/repo', targetSessionId: 'target', originSessionId: 'origin',
22
+ userBody: `user ${index}`, assistantBody: `assistant ${index}`, completedAt: index,
23
+ };
24
+ }
25
+
26
+ test('completed turn receipt: private atomic store records only identities and hashes', () => {
27
+ const box = sandbox();
28
+ try {
29
+ const receipt = writeCompletedTurnReceipt(input(), { storePath: box.storePath });
30
+ assert.equal(receipt.schema_version, '1.0');
31
+ assert.equal(receipt.host, 'claude');
32
+ assert.equal(receipt.sequence, 1);
33
+ assert.match(receipt.project_sha256, /^[0-9a-f]{64}$/);
34
+ assert.match(receipt.user_sha256, /^[0-9a-f]{64}$/);
35
+ assert.match(receipt.assistant_sha256, /^[0-9a-f]{64}$/);
36
+ const bytes = readFileSync(box.storePath, 'utf8');
37
+ assert.doesNotMatch(bytes, /user 1|assistant 1|\/repo/);
38
+ const stored = JSON.parse(bytes);
39
+ assert.equal(stored.schema, COMPLETED_TURN_RECEIPT_STORE_SCHEMA);
40
+ assert.equal(stored.history_floor, 1);
41
+ assert.equal(stored.receipts.length, 1);
42
+ } finally {
43
+ rmSync(box.root, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test('completed turn receipt: same project/session/pair is idempotent and keeps its sequence', () => {
48
+ const box = sandbox();
49
+ try {
50
+ const first = writeCompletedTurnReceipt(input(), { storePath: box.storePath });
51
+ const retry = writeCompletedTurnReceipt({ ...input(), completedAt: 999 }, { storePath: box.storePath });
52
+ assert.deepEqual(retry, first);
53
+ const next = writeCompletedTurnReceipt(input(2), { storePath: box.storePath });
54
+ assert.equal(next.sequence, 2);
55
+ } finally {
56
+ rmSync(box.root, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test('completed turn receipt: target変更を伴う同一origin/pairの再実行は冪等', () => {
61
+ const box = sandbox();
62
+ try {
63
+ const first = writeCompletedTurnReceipt(input(), { storePath: box.storePath });
64
+ const retriedAfterMerge = writeCompletedTurnReceipt({ ...input(), targetSessionId: 'merged-target' }, { storePath: box.storePath });
65
+ assert.deepEqual(retriedAfterMerge, first);
66
+ assert.equal(retriedAfterMerge.target_session_id, 'target', 'capture時targetを保持する');
67
+ } finally {
68
+ rmSync(box.root, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ test('completed turn receipt: project digest shares realpath canonicalization with the DB projection', {
73
+ skip: process.platform === 'win32',
74
+ }, () => {
75
+ const box = sandbox();
76
+ const project = join(box.root, 'project');
77
+ const alias = join(box.root, 'project-alias');
78
+ try {
79
+ mkdirSync(project);
80
+ symlinkSync(project, alias);
81
+ const direct = writeCompletedTurnReceipt({ ...input(), projectPath: project }, { storePath: box.storePath });
82
+ const viaAlias = writeCompletedTurnReceipt({ ...input(), projectPath: alias }, { storePath: box.storePath });
83
+ assert.deepEqual(viaAlias, direct);
84
+ } finally {
85
+ rmSync(box.root, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ test('completed turn receipt: rejects symlinked store paths', { skip: process.platform === 'win32' }, () => {
90
+ const box = sandbox();
91
+ const target = join(box.root, 'target.json');
92
+ try {
93
+ mkdirSync(dirname(box.storePath), { recursive: true });
94
+ symlinkSync(target, box.storePath);
95
+ assert.throws(() => writeCompletedTurnReceipt(input(), { storePath: box.storePath }), /unsafe/);
96
+ } finally {
97
+ rmSync(box.root, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ test('completed turn receipt: bounded store drops only oldest receipts', () => {
102
+ const box = sandbox();
103
+ try {
104
+ for (let index = 1; index <= COMPLETED_TURN_RECEIPT_LIMIT + 1; index++) {
105
+ writeCompletedTurnReceipt({ ...input(index), targetSessionId: `target-${index}` }, { storePath: box.storePath });
106
+ }
107
+ const stored = JSON.parse(readFileSync(box.storePath, 'utf8'));
108
+ assert.equal(stored.receipts.length, COMPLETED_TURN_RECEIPT_LIMIT);
109
+ assert.equal(stored.history_floor, 2);
110
+ assert.equal(stored.receipts[0].sequence, 2);
111
+ assert.equal(stored.receipts.at(-1).sequence, COMPLETED_TURN_RECEIPT_LIMIT + 1);
112
+ } finally {
113
+ rmSync(box.root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test('completed turn receipt: noisy project cannot evict another project anchor', () => {
118
+ const box = sandbox();
119
+ const env = { HOME: box.root, USERPROFILE: box.root, XDG_STATE_HOME: join(box.root, 'state-home') };
120
+ try {
121
+ const quiet = writeCompletedTurnReceipt({ ...input(), projectPath: '/quiet-project' }, { env });
122
+ for (let index = 1; index <= COMPLETED_TURN_RECEIPT_LIMIT + 1; index++) {
123
+ writeCompletedTurnReceipt({ ...input(index), projectPath: '/noisy-project', targetSessionId: `target-${index}` }, { env });
124
+ }
125
+ const quietStorePath = defaultCompletedTurnReceiptStorePath(quiet.project_sha256, env);
126
+ const quietStore = JSON.parse(readFileSync(quietStorePath, 'utf8'));
127
+ assert.equal(quietStore.receipts.length, 1);
128
+ assert.equal(quietStore.receipts[0].sequence, 1);
129
+ } finally {
130
+ rmSync(box.root, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ test('completed turn receipt: explicit store path rejects a second project', () => {
135
+ const box = sandbox();
136
+ try {
137
+ writeCompletedTurnReceipt({ ...input(), projectPath: '/project-a' }, { storePath: box.storePath });
138
+ assert.throws(
139
+ () => writeCompletedTurnReceipt({ ...input(), projectPath: '/project-b' }, { storePath: box.storePath }),
140
+ /schema invalid/,
141
+ );
142
+ } finally {
143
+ rmSync(box.root, { recursive: true, force: true });
144
+ }
145
+ });
146
+
147
+ test('completed turn receipt: read-only snapshot validates project binding and does not create missing state', () => {
148
+ const box = sandbox();
149
+ try {
150
+ const missing = readCompletedTurnReceiptSnapshot({ projectPath: '/missing-project', storePath: box.storePath });
151
+ assert.equal(missing.receipts.length, 0);
152
+ assert.throws(() => readFileSync(box.storePath));
153
+ writeCompletedTurnReceipt({ ...input(), projectPath: '/project-a' }, { storePath: box.storePath });
154
+ assert.throws(
155
+ () => readCompletedTurnReceiptSnapshot({ projectPath: '/project-b', storePath: box.storePath }),
156
+ /schema invalid/,
157
+ );
158
+ assert.throws(() => readCompletedTurnReceiptSnapshot({ projectPath: '/project-a', unknown: true }), /options are invalid/);
159
+ assert.throws(() => readCompletedTurnReceiptSnapshot({ projectPath: null }), /projectPath/);
160
+ } finally {
161
+ rmSync(box.root, { recursive: true, force: true });
162
+ }
163
+ });
164
+
165
+ test('completed turn receipt: rejects sequence order corruption', () => {
166
+ const box = sandbox();
167
+ try {
168
+ writeCompletedTurnReceipt(input(1), { storePath: box.storePath });
169
+ writeCompletedTurnReceipt(input(2), { storePath: box.storePath });
170
+ const store = JSON.parse(readFileSync(box.storePath, 'utf8'));
171
+ [store.receipts[0], store.receipts[1]] = [store.receipts[1], store.receipts[0]];
172
+ writeFileSync(box.storePath, `${JSON.stringify(store)}\n`);
173
+ assert.throws(() => writeCompletedTurnReceipt(input(3), { storePath: box.storePath }), /uniqueness invalid/);
174
+ } finally {
175
+ rmSync(box.root, { recursive: true, force: true });
176
+ }
177
+ });
178
+
179
+ test('completed turn receipt: Windows path uses LocalAppData', () => {
180
+ const env = { OS: 'Windows_NT', USERPROFILE: 'C:\\Users\\kite', LOCALAPPDATA: 'C:\\Users\\kite\\AppData\\Local' };
181
+ const projectSha256 = 'a'.repeat(64);
182
+ assert.equal(
183
+ defaultCompletedTurnReceiptStorePath(projectSha256, env),
184
+ join(env.LOCALAPPDATA, 'throughline', 'completed-turn-receipts', `${projectSha256}.json`),
185
+ );
186
+ });
@@ -38,10 +38,10 @@ function indexNames(db) {
38
38
  .map((row) => row.name);
39
39
  }
40
40
 
41
- test('schema v8 preserves Claude-facing tables, fields, and unique indexes', async () => {
41
+ test('schema v9 preserves Claude-facing tables, fields, and unique indexes', async () => {
42
42
  await withIsolatedDb((db) => {
43
43
  const version = db.prepare('PRAGMA user_version').get();
44
- assert.equal(version.user_version, 8);
44
+ assert.equal(version.user_version, 9);
45
45
 
46
46
  assert.deepEqual(columnNames(db, 'sessions'), [
47
47
  'session_id',
@@ -88,6 +88,13 @@ test('schema v8 preserves Claude-facing tables, fields, and unique indexes', asy
88
88
  'session_id',
89
89
  'created_at',
90
90
  ]);
91
+ assert.deepEqual(columnNames(db, 'pending_handoffs'), [
92
+ 'session_id',
93
+ 'project_path',
94
+ 'source',
95
+ 'auto_predecessor_id',
96
+ 'created_at',
97
+ ]);
91
98
 
92
99
  const indexes = indexNames(db);
93
100
  assert.ok(indexes.includes('uq_skeletons_turn_v3'));
package/src/db.mjs CHANGED
@@ -9,7 +9,7 @@ import { join } from 'path';
9
9
 
10
10
  const DB_DIR = join(homedir(), '.throughline');
11
11
  const DB_PATH = join(DB_DIR, 'throughline.db');
12
- export const CURRENT_VERSION = 8;
12
+ export const CURRENT_VERSION = 9;
13
13
 
14
14
  let _db = null;
15
15
 
@@ -215,6 +215,25 @@ function initSchema(db) {
215
215
  }
216
216
  }
217
217
 
218
+ // v8 → v9: pending_handoffs テーブル追加(二相ハンドオフ)。
219
+ // SessionStart は merge / 注入をせず intent をここに登録するだけ。
220
+ // 最初の UserPromptSubmit(= セッション実在の証明。幽霊 SessionStart は
221
+ // プロンプトを一度も発火しない)が原子的に consume して merge + 注入する。
222
+ // auto path (source='clear') の前任は SessionStart 時点で解決して凍結する。
223
+ // 幽霊の pending 行は誰にも consume されず無害に残る(行は数百バイト)。
224
+ // 経緯: 2026-07-17 の baton 幽霊奪取 incident(ADR 0014)。
225
+ if (version < 9) {
226
+ db.exec(`
227
+ CREATE TABLE IF NOT EXISTS pending_handoffs (
228
+ session_id TEXT PRIMARY KEY,
229
+ project_path TEXT NOT NULL,
230
+ source TEXT,
231
+ auto_predecessor_id TEXT,
232
+ created_at INTEGER NOT NULL
233
+ );
234
+ `);
235
+ }
236
+
218
237
  if (version < CURRENT_VERSION) {
219
238
  db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
220
239
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * decision-log.mjs — 引き継ぎ判定ログ (~/.throughline/logs/inheritance-decision.log)
3
+ *
4
+ * 二相ハンドオフでは SessionStart (intent 登録) と UserPromptSubmit (consume + merge)
5
+ * の両方が判定に関与するため、共有モジュールに切り出す。各エントリは `phase` field
6
+ * ('session-start' | 'prompt-submit') で区別する。
7
+ * このログは 2026-07-17 の幽霊バトン奪取 incident の一次証拠になった実績があり、
8
+ * 事故調査の生命線。書き込み失敗は stderr に出す(黙って握りつぶさない)。
9
+ */
10
+
11
+ import { appendFileSync, mkdirSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ export function logDecision(entry) {
16
+ const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
17
+ try {
18
+ mkdirSync(dirname(path), { recursive: true });
19
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
20
+ } catch (err) {
21
+ const msg = err instanceof Error ? err.message : 'unknown';
22
+ process.stderr.write(`[decision-log] ${msg}\n`);
23
+ }
24
+ }