throughline 0.6.2 → 0.7.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 +79 -4
  2. package/README.md +77 -25
  3. package/bin/throughline.mjs +14 -0
  4. package/docs/00_overview.md +12 -0
  5. package/docs/02_clear_auto_handoff_plan.md +39 -13
  6. package/docs/04_public_release_plan.md +2 -1
  7. package/docs/13_native_factory_diagnostics_plan.md +4 -2
  8. package/docs/14_observer_completed_turn_feed_plan.md +290 -0
  9. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
  10. package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
  11. package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
  12. package/docs/adr/0004-observer-db-pair-projection.md +71 -0
  13. package/docs/adr/0005-observer-read-pagination.md +51 -0
  14. package/docs/adr/0006-observer-page-offset-proof.md +33 -0
  15. package/docs/adr/0007-observer-read-cli-contract.md +61 -0
  16. package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
  17. package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
  18. package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
  19. package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
  20. package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
  21. package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
  22. package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
  23. package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
  24. package/package.json +1 -1
  25. package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -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 +5 -2
  34. package/src/cli/factory-diagnostics.test.mjs +31 -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/codex-rollout-memory.mjs +13 -0
  40. package/src/codex-rollout-memory.test.mjs +27 -0
  41. package/src/codex-thread-index.mjs +1 -1
  42. package/src/codex-thread-index.test.mjs +18 -0
  43. package/src/completed-turn-receipts.mjs +373 -0
  44. package/src/completed-turn-receipts.test.mjs +186 -0
  45. package/src/db-schema.test.mjs +9 -2
  46. package/src/db.mjs +20 -1
  47. package/src/decision-log.mjs +24 -0
  48. package/src/factory-diagnostics.mjs +0 -1
  49. package/src/factory-diagnostics.test.mjs +18 -0
  50. package/src/haiku-summarizer.mjs +93 -16
  51. package/src/haiku-summarizer.test.mjs +118 -9
  52. package/src/handoff-executor.mjs +159 -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 +226 -62
  63. package/src/resume-context.test.mjs +134 -1
  64. package/src/runtime-error-store.mjs +74 -32
  65. package/src/runtime-error-store.test.mjs +51 -3
  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 +141 -0
  72. package/src/windows-acl-test-helper.mjs +29 -0
@@ -9,7 +9,7 @@ import {
9
9
  rmSync,
10
10
  writeFileSync,
11
11
  } from 'node:fs';
12
- import { spawnSync } from 'node:child_process';
12
+ import childProcess from 'node:child_process';
13
13
  import { arch as hostArch, homedir, platform as hostPlatform } from 'node:os';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { createRequire } from 'node:module';
@@ -27,6 +27,7 @@ const BEST_EFFORT_TIMEOUT_MS = 750;
27
27
  const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
28
28
  const WINDOWS_ACL_TIMEOUT_MS = 3_000;
29
29
  const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
30
+ const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
30
31
 
31
32
  const DEFINITIONS = Object.freeze({
32
33
  HOOK_SESSION_START_FAILED: Object.freeze({
@@ -86,13 +87,13 @@ export function observeRuntimeError(input, options = {}) {
86
87
  if (!definition) throw new TypeError('未登録の runtime error code です');
87
88
  if (!collectionEnabled(options)) return { status: 'disabled' };
88
89
 
89
- return withStoreLock(options, () => {
90
+ return withStoreLock(options, (privateDirectory) => {
90
91
  const now = normalizeTimestamp(input.now);
91
92
  const version = normalizeVersion(options.version ?? PACKAGE_VERSION);
92
93
  const fingerprint = createHash('sha256')
93
94
  .update(['throughline', definition.component, input.code, definition.template].join('\0'))
94
95
  .digest('hex');
95
- const store = readStore(options);
96
+ const store = readStore(options, { privateDirectory });
96
97
  const existing = store.records.find((record) => record.fingerprint === fingerprint);
97
98
  const sequence = nextSequence(store);
98
99
  if (existing) {
@@ -115,7 +116,7 @@ export function observeRuntimeError(input, options = {}) {
115
116
  status: 'open', resolved_at: null, reason_code: null, sequence,
116
117
  });
117
118
  }
118
- writeStore(store, options);
119
+ writeStore(store, options, privateDirectory);
119
120
  return { status: 'recorded', fingerprint, sequence };
120
121
  });
121
122
  }
@@ -124,8 +125,8 @@ export function resolveRuntimeError(fingerprint, options = {}) {
124
125
  assertExactOptions(options, ['env', 'configPath', 'storePath', 'now', 'reasonCode']);
125
126
  assertFingerprint(fingerprint);
126
127
  if (!collectionEnabled(options)) return { status: 'disabled' };
127
- return withStoreLock(options, () => {
128
- const store = readStore(options);
128
+ return withStoreLock(options, (privateDirectory) => {
129
+ const store = readStore(options, { privateDirectory });
129
130
  const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
130
131
  if (!record) return { status: 'not_found' };
131
132
  if (record.status === 'resolved') return { status: 'resolved', sequence: record.sequence };
@@ -135,7 +136,7 @@ export function resolveRuntimeError(fingerprint, options = {}) {
135
136
  record.resolved_at = normalizeTimestamp(options.now);
136
137
  record.reason_code = reasonCode;
137
138
  record.sequence = nextSequence(store);
138
- writeStore(store, options);
139
+ writeStore(store, options, privateDirectory);
139
140
  return { status: 'resolved', sequence: record.sequence };
140
141
  });
141
142
  }
@@ -144,8 +145,8 @@ export function reopenRuntimeError(fingerprint, options = {}) {
144
145
  assertExactOptions(options, ['env', 'configPath', 'storePath']);
145
146
  assertFingerprint(fingerprint);
146
147
  if (!collectionEnabled(options)) return { status: 'disabled' };
147
- return withStoreLock(options, () => {
148
- const store = readStore(options);
148
+ return withStoreLock(options, (privateDirectory) => {
149
+ const store = readStore(options, { privateDirectory });
149
150
  const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
150
151
  if (!record) return { status: 'not_found' };
151
152
  if (record.status === 'open') return { status: 'open', sequence: record.sequence };
@@ -153,7 +154,7 @@ export function reopenRuntimeError(fingerprint, options = {}) {
153
154
  record.resolved_at = null;
154
155
  record.reason_code = null;
155
156
  record.sequence = nextSequence(store);
156
- writeStore(store, options);
157
+ writeStore(store, options, privateDirectory);
157
158
  return { status: 'open', sequence: record.sequence };
158
159
  });
159
160
  }
@@ -161,12 +162,12 @@ export function reopenRuntimeError(fingerprint, options = {}) {
161
162
  export function acknowledgeRuntimeErrors(cursor, options = {}) {
162
163
  if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('cursor は非負の整数が必要です');
163
164
  if (!collectionEnabled(options)) return { status: 'disabled', acknowledgedThrough: 0 };
164
- return withStoreLock(options, () => {
165
- const store = readStore(options);
165
+ return withStoreLock(options, (privateDirectory) => {
166
+ const store = readStore(options, { privateDirectory });
166
167
  const highWatermark = store.next_sequence - 1;
167
168
  if (cursor > highWatermark) throw new RangeError('cursor がstore high watermarkを超えています');
168
169
  store.acknowledged_through = Math.max(store.acknowledged_through, cursor);
169
- writeStore(store, options);
170
+ writeStore(store, options, privateDirectory);
170
171
  return { status: 'acknowledged', acknowledgedThrough: store.acknowledged_through };
171
172
  });
172
173
  }
@@ -183,8 +184,8 @@ export function compactRuntimeErrors({
183
184
  }
184
185
  const options = { env, configPath, storePath };
185
186
  if (!collectionEnabled(options)) return { status: 'disabled', removed: 0 };
186
- return withStoreLock(options, () => {
187
- const store = readStore(options);
187
+ return withStoreLock(options, (privateDirectory) => {
188
+ const store = readStore(options, { privateDirectory });
188
189
  const cutoff = Date.parse(normalizeTimestamp(now)) - retentionMs;
189
190
  const before = store.records.length;
190
191
  store.records = store.records.filter((record) => {
@@ -192,7 +193,7 @@ export function compactRuntimeErrors({
192
193
  const expired = Date.parse(record.last_seen) <= cutoff;
193
194
  return !(acknowledged && record.status === 'resolved' && expired);
194
195
  });
195
- writeStore(store, options);
196
+ writeStore(store, options, privateDirectory);
196
197
  return { status: 'compacted', removed: before - store.records.length };
197
198
  });
198
199
  }
@@ -256,7 +257,7 @@ export function getRuntimeErrorDiagnostics(options = {}) {
256
257
  export function recordRuntimeErrorBestEffort(code, options = {}) {
257
258
  const { stderr = process.stderr, ...storeOptions } = options;
258
259
  try {
259
- const child = spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
260
+ const child = childProcess.spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
260
261
  env: storeOptions.env ?? process.env,
261
262
  encoding: 'utf8',
262
263
  stdio: 'ignore',
@@ -288,13 +289,13 @@ function emptyStore() {
288
289
  };
289
290
  }
290
291
 
291
- function readStore(options, { missingIsEmpty = true } = {}) {
292
+ function readStore(options, { missingIsEmpty = true, privateDirectory } = {}) {
292
293
  const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
293
294
  let parsed;
294
295
  try {
295
296
  const info = lstatSync(path);
296
297
  if (!info.isFile() || info.isSymbolicLink()) throw new Error('runtime error store path unsafe');
297
- assertPrivateStoreDirectory(dirname(path), options.env);
298
+ assertPrivateDirectoryCapability(privateDirectory, dirname(path), options.env);
298
299
  assertPrivateStoreFile(info, options.env, path);
299
300
  parsed = JSON.parse(readFileSync(path, 'utf8'));
300
301
  } catch (error) {
@@ -305,19 +306,28 @@ function readStore(options, { missingIsEmpty = true } = {}) {
305
306
  return parsed;
306
307
  }
307
308
 
308
- function writeStore(store, options) {
309
+ function writeStore(store, options, privateDirectory) {
309
310
  validateStore(store);
310
311
  const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
311
312
  const directory = dirname(path);
312
- ensurePrivateStoreDirectory(directory, options.env);
313
+ assertPrivateDirectoryCapability(privateDirectory, directory, options.env);
313
314
  const temporary = join(directory, `.runtime-errors.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
314
315
  try {
315
316
  writeFileSync(temporary, `${JSON.stringify(store)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
316
- if (!isWindows(options.env)) chmodSync(temporary, 0o600);
317
+ if (!isWindows(options.env)) {
318
+ chmodSync(temporary, 0o600);
319
+ } else {
320
+ // The apply script performs an exact read-back verification. Repeating
321
+ // the same PowerShell verification here only consumes the bounded hook
322
+ // observer deadline without protecting a new state transition.
323
+ applyAndVerifyWindowsAcl(temporary, false);
324
+ assertPrivateStoreFileShape(lstatSync(temporary));
325
+ }
326
+ // ACL/mode is complete before replacement, so an ACL failure leaves the
327
+ // previous final store intact. Rename preserves the prepared file ACL.
317
328
  renameSync(temporary, path);
318
- if (!isWindows(options.env)) chmodSync(path, 0o600);
319
- else applyAndVerifyWindowsAcl(path, false);
320
- assertPrivateStoreFile(lstatSync(path), options.env, path);
329
+ if (!isWindows(options.env)) assertPrivateStoreFile(lstatSync(path), options.env, path);
330
+ else assertPrivateStoreFileShape(lstatSync(path));
321
331
  } finally {
322
332
  rmSync(temporary, { force: true });
323
333
  }
@@ -339,15 +349,15 @@ function withStoreLock(options, operation) {
339
349
  if (isWindows(options.env)) applyAndVerifyWindowsAcl(lockPath, false);
340
350
  else chmodSync(lockPath, 0o600);
341
351
  }
342
- assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
352
+ if (created && isWindows(options.env)) assertPrivateStoreFileShape(lstatSync(lockPath));
353
+ else assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
343
354
  const database = new DatabaseSync(lockPath);
344
355
  let active = false;
345
356
  try {
346
- assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
347
357
  database.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL');
348
358
  database.exec('BEGIN IMMEDIATE');
349
359
  active = true;
350
- const result = operation();
360
+ const result = operation(privateDirectoryCapability(directory, options.env));
351
361
  database.exec('COMMIT');
352
362
  active = false;
353
363
  return result;
@@ -363,10 +373,42 @@ function ensurePrivateStoreDirectory(directory, env = process.env) {
363
373
  if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('runtime error store directory unsafe');
364
374
  if (isWindows(env)) {
365
375
  applyAndVerifyWindowsAcl(directory, true);
376
+ assertPrivateStoreDirectoryShape(lstatSync(directory));
366
377
  } else {
367
378
  chmodSync(directory, 0o700);
379
+ assertPrivateStoreDirectory(directory, env);
380
+ }
381
+ }
382
+
383
+ function privateDirectoryCapability(directory, env) {
384
+ return Object.freeze({
385
+ directory,
386
+ windows: isWindows(env),
387
+ [PRIVATE_DIRECTORY_CAPABILITY]: true,
388
+ });
389
+ }
390
+
391
+ function assertPrivateDirectoryCapability(capability, directory, env) {
392
+ if (!capability || capability[PRIVATE_DIRECTORY_CAPABILITY] !== true ||
393
+ capability.directory !== directory || capability.windows !== isWindows(env)) {
394
+ // Read-only callers do not hold a mutation capability and still perform
395
+ // the complete ACL/mode verification immediately before reading.
396
+ assertPrivateStoreDirectory(directory, env);
397
+ return;
398
+ }
399
+ assertPrivateStoreDirectoryShape(lstatSync(directory));
400
+ }
401
+
402
+ function assertPrivateStoreDirectoryShape(info) {
403
+ if (!info.isDirectory() || info.isSymbolicLink()) {
404
+ throw new Error('runtime error store directory unsafe');
405
+ }
406
+ }
407
+
408
+ function assertPrivateStoreFileShape(info) {
409
+ if (!info.isFile() || info.isSymbolicLink()) {
410
+ throw new Error('runtime error store path unsafe');
368
411
  }
369
- assertPrivateStoreDirectory(directory, env);
370
412
  }
371
413
 
372
414
  function isWindows(env = process.env) {
@@ -496,13 +538,13 @@ function isCanonicalTimestamp(value) {
496
538
 
497
539
  function assertPrivateStoreDirectory(directory, env = process.env) {
498
540
  const info = lstatSync(directory);
499
- if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('runtime error store directory unsafe');
541
+ assertPrivateStoreDirectoryShape(info);
500
542
  if (isWindows(env)) verifyWindowsAcl(directory, true);
501
543
  else assertPosixOwnerMode(info, 0o700);
502
544
  }
503
545
 
504
546
  function assertPrivateStoreFile(info, env = process.env, path) {
505
- if (!info.isFile() || info.isSymbolicLink()) throw new Error('runtime error store path unsafe');
547
+ assertPrivateStoreFileShape(info);
506
548
  if (isWindows(env)) verifyWindowsAcl(path, false);
507
549
  else assertPosixOwnerMode(info, 0o600);
508
550
  }
@@ -521,7 +563,7 @@ function verifyWindowsAcl(path, directory) {
521
563
  }
522
564
 
523
565
  function runWindowsAclScript(path, directory, script) {
524
- const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
566
+ const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
525
567
  env: { ...process.env, FACTORY_ACL_PATH: path, FACTORY_ACL_DIRECTORY: directory ? '1' : '0' },
526
568
  stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
527
569
  });
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { spawn } from 'node:child_process';
4
- import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import childProcess, { spawn } from 'node:child_process';
4
+ import { chmodSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
 
@@ -16,7 +16,7 @@ import {
16
16
  reopenRuntimeError,
17
17
  resolveRuntimeError,
18
18
  } from './runtime-error-store.mjs';
19
- import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
19
+ import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
20
20
 
21
21
  const TEST_PLATFORM = process.platform === 'win32' ? 'win32' : 'darwin';
22
22
 
@@ -83,6 +83,50 @@ test('runtime error store: Windows native uses the canonical LocalAppData paths'
83
83
  );
84
84
  });
85
85
 
86
+ test('runtime error store: one Windows mutation spends ACL processes only on distinct state transitions', (t) => {
87
+ const box = sandbox();
88
+ box.env.OS = 'Windows_NT';
89
+ enableCollection(box);
90
+ const calls = [];
91
+ t.mock.method(childProcess, 'spawnSync', (command, args, options) => {
92
+ calls.push({ command, args, options });
93
+ return { status: 0, signal: null, error: undefined };
94
+ });
95
+ const options = { env: box.env, configPath: box.configPath, storePath: box.storePath };
96
+
97
+ assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
98
+ assert.equal(calls.length, 3, 'new directory, lock, and store each require one apply+verify process');
99
+ assert.ok(calls.every((call) => call.command === 'powershell.exe'));
100
+ calls.length = 0;
101
+
102
+ assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
103
+ assert.equal(calls.length, 4, 'directory apply, existing lock/store verify, and replacement store apply are distinct');
104
+ assert.ok(calls.every((call) => call.options.timeout === 3_000));
105
+ });
106
+
107
+ test('runtime error store: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
108
+ const box = sandbox();
109
+ box.env.OS = 'Windows_NT';
110
+ enableCollection(box);
111
+ let calls = 0;
112
+ let failAt = Number.POSITIVE_INFINITY;
113
+ t.mock.method(childProcess, 'spawnSync', () => {
114
+ calls += 1;
115
+ return { status: calls === failAt ? 1 : 0, signal: null, error: undefined };
116
+ });
117
+ const options = { env: box.env, configPath: box.configPath, storePath: box.storePath };
118
+
119
+ observeRuntimeError({ code: 'HOOK_CODEX_FAILED', now: '2026-07-13T00:00:00.000Z' }, options);
120
+ const before = readFileSync(box.storePath, 'utf8');
121
+ failAt = calls + 4;
122
+ assert.throws(
123
+ () => observeRuntimeError({ code: 'HOOK_CODEX_FAILED', now: '2026-07-13T00:01:00.000Z' }, options),
124
+ /Windows owner-only ACL verification failed/,
125
+ );
126
+ assert.equal(readFileSync(box.storePath, 'utf8'), before);
127
+ assert.deepEqual(readdirSync(dirname(box.storePath)).filter((name) => name.endsWith('.tmp')), []);
128
+ });
129
+
86
130
  test('runtime error store: reporting config and credentials are ignored and no network API is accepted', () => {
87
131
  const box = sandbox();
88
132
  enableCollection(box, {
@@ -235,6 +279,10 @@ test('runtime error store: atomic private store has owner-only modes and bounded
235
279
  if (process.platform !== 'win32') {
236
280
  assert.equal(statSync(dirname(box.storePath)).mode & 0o777, 0o700);
237
281
  assert.equal(statSync(box.storePath).mode & 0o777, 0o600);
282
+ } else {
283
+ verifyWindowsPrivateAcl(dirname(box.storePath), true);
284
+ verifyWindowsPrivateAcl(`${box.storePath}.lock.sqlite`);
285
+ verifyWindowsPrivateAcl(box.storePath);
238
286
  }
239
287
  assert.doesNotThrow(() => JSON.parse(readFileSync(box.storePath, 'utf8')));
240
288