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
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { existsSync, realpathSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { isAbsolute, join, resolve, sep } from 'node:path';
@@ -6,25 +5,21 @@ import { DatabaseSync } from 'node:sqlite';
6
5
  import { buildBodyRowsFromActiveTurns } from './codex-capture.mjs';
7
6
  import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
8
7
  import { getLogicalTurnGroups } from './transcript-reader.mjs';
8
+ import { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
9
+
10
+ export { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
9
11
 
10
12
  export const AUDITOR_CONTEXT_SCHEMA = 'throughline.auditor_context.v1';
11
- export const AUDITOR_CONTEXT_DB_SCHEMA_VERSION = 8;
13
+ export const AUDITOR_CONTEXT_DB_SCHEMA_VERSION = 9;
12
14
  export const DEFAULT_AUDITOR_RECENT_TURNS = 2;
13
15
  export const DEFAULT_AUDITOR_MAX_BODY_CHARS = 1200;
14
16
  export const DEFAULT_AUDITOR_MAX_TOTAL_CHARS = 4000;
17
+ const OBSERVER_PROJECTION_BUSY_TIMEOUT_MS = 1_000;
15
18
 
16
19
  export function defaultAuditorContextDbPath() {
17
20
  return join(homedir(), '.throughline', 'throughline.db');
18
21
  }
19
22
 
20
- export function normalizeAuditorBody(value) {
21
- return String(value ?? '').normalize('NFC').replace(/\r\n?/g, '\n').trim();
22
- }
23
-
24
- export function hashAuditorBody(value) {
25
- return createHash('sha256').update(normalizeAuditorBody(value), 'utf8').digest('hex');
26
- }
27
-
28
23
  export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessionId } = {}) {
29
24
  assertNonEmptyString(transcriptPath, 'transcriptPath');
30
25
  assertNonEmptyString(sessionId, 'sessionId');
@@ -182,6 +177,44 @@ export function readAuditorContext({
182
177
  }
183
178
  }
184
179
 
180
+ /** Read-only, ordered completed-pair projection for the Observer feed. */
181
+ export function readCompletedPairProjection({
182
+ dbPath = defaultAuditorContextDbPath(), sessionId, projectRoot, expectedPairs,
183
+ maxBodyChars = DEFAULT_AUDITOR_MAX_BODY_CHARS, maxTotalChars = DEFAULT_AUDITOR_MAX_TOTAL_CHARS,
184
+ } = {}) {
185
+ assertNonEmptyString(sessionId, 'sessionId');
186
+ assertNonEmptyString(projectRoot, 'projectRoot');
187
+ assertExpectedPairs(expectedPairs);
188
+ assertPositiveInteger(maxBodyChars, 'maxBodyChars');
189
+ if (!Number.isInteger(maxTotalChars) || maxTotalChars < 0) throw new TypeError('maxTotalChars must be an integer >= 0');
190
+ if (!existsSync(dbPath)) return { status: 'pending', reason: 'db_not_found', turns: [] };
191
+ let db;
192
+ try {
193
+ db = new DatabaseSync(dbPath, { readOnly: true });
194
+ db.exec(`PRAGMA busy_timeout = ${OBSERVER_PROJECTION_BUSY_TIMEOUT_MS}`);
195
+ } catch (cause) {
196
+ db?.close();
197
+ throw new AuditorContextError('E_AUDITOR_CONTEXT_DB_OPEN', 'auditor context DB could not be opened', { cause });
198
+ }
199
+ try {
200
+ const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
201
+ if (version !== AUDITOR_CONTEXT_DB_SCHEMA_VERSION) throw new AuditorContextError('E_AUDITOR_CONTEXT_SCHEMA', 'auditor context DB schema is unsupported');
202
+ const session = db.prepare('SELECT session_id, project_path FROM sessions WHERE session_id = ?').get(sessionId);
203
+ if (!session) return { status: 'pending', reason: 'session_not_found', turns: [] };
204
+ if (!isSameProjectOrDescendant(session.project_path, projectRoot)) throw new AuditorContextError('E_AUDITOR_CONTEXT_PROJECT', 'auditor context DB project does not match');
205
+ const rows = db.prepare(
206
+ `SELECT id, origin_session_id, turn_number, role, text, created_at FROM bodies
207
+ WHERE session_id = ? AND role IN ('user', 'assistant') ORDER BY created_at ASC, id ASC`,
208
+ ).all(sessionId);
209
+ const matched = matchExpectedPairs(buildCompletedPairs(rows), expectedPairs);
210
+ if (!matched) return { status: 'pending', reason: 'pair_not_found', turns: [] };
211
+ return { status: 'fresh', turns: boundProjectedPairs(matched, { maxBodyChars, maxTotalChars }) };
212
+ } catch (cause) {
213
+ if (cause instanceof AuditorContextError) throw cause;
214
+ throw new AuditorContextError('E_AUDITOR_CONTEXT_QUERY', 'auditor context query failed', { cause });
215
+ } finally { db.close(); }
216
+ }
217
+
185
218
  export class AuditorContextError extends Error {
186
219
  constructor(code, message, { cause } = {}) {
187
220
  super(message, { cause });
@@ -262,6 +295,35 @@ function boundCompletedPairs(pairs, { maxBodyChars, maxTotalChars }) {
262
295
  return { turns: selected, chars, truncated };
263
296
  }
264
297
 
298
+ function matchExpectedPairs(completed, expectedPairs) {
299
+ const matched = [];
300
+ let searchStart = 0;
301
+ for (const expected of expectedPairs) {
302
+ const index = completed.findIndex((pair, pairIndex) => pairIndex >= searchStart &&
303
+ hashAuditorBody(pair.originSessionId) === expected.origin_sha256 &&
304
+ hashAuditorBody(pair.user) === expected.user_sha256 && hashAuditorBody(pair.assistant) === expected.assistant_sha256);
305
+ if (index < 0) return null;
306
+ matched.push({ ...completed[index], expected });
307
+ searchStart = index + 1;
308
+ }
309
+ return matched;
310
+ }
311
+
312
+ function boundProjectedPairs(pairs, { maxBodyChars, maxTotalChars }) {
313
+ let remaining = maxTotalChars;
314
+ return pairs.map((pair) => {
315
+ const user = tail(pair.user, Math.min(maxBodyChars, remaining));
316
+ remaining -= user.length;
317
+ const assistant = tail(pair.assistant, Math.min(maxBodyChars, remaining));
318
+ remaining -= assistant.length;
319
+ return {
320
+ origin_sha256: pair.expected.origin_sha256, user_sha256: pair.expected.user_sha256,
321
+ assistant_sha256: pair.expected.assistant_sha256, user, assistant,
322
+ truncated: user.length < pair.user.length || assistant.length < pair.assistant.length,
323
+ };
324
+ });
325
+ }
326
+
265
327
  function emptyResult(status, reason, { sessionId, projectRoot, recentTurns, dbSchemaVersion } = {}) {
266
328
  return {
267
329
  schema: AUDITOR_CONTEXT_SCHEMA,
@@ -289,7 +351,17 @@ function emptyResult(status, reason, { sessionId, projectRoot, recentTurns, dbSc
289
351
  function canonicalProjectPath(value) {
290
352
  const raw = String(value ?? '');
291
353
  if (/^[A-Za-z]:[\\/]/.test(raw)) {
292
- return raw.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
354
+ // Windows の 8.3 短縮名 (例: RUNNER~1) を実体の長い名前へ解決してから比較形へ
355
+ // 正規化する。GitHub Actions は TEMP が短縮名で来るため、realpath を挟まないと
356
+ // 同一ディレクトリの短縮形/長形が別 project と判定される (codex-thread-index の
357
+ // normalizePath と同型)。存在しないパスは lexical のまま (fixture の仮パスを壊さない)。
358
+ let resolved = raw;
359
+ try {
360
+ if (existsSync(raw)) resolved = realpathSync.native(raw);
361
+ } catch {
362
+ // Keep the lexical Windows path when it cannot be resolved.
363
+ }
364
+ return resolved.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
293
365
  }
294
366
  let normalized = isAbsolute(raw) ? raw : resolve(raw);
295
367
  try {
@@ -328,3 +400,12 @@ function assertPositiveInteger(value, name) {
328
400
  throw new TypeError(`${name} must be an integer >= 1`);
329
401
  }
330
402
  }
403
+
404
+ function assertExpectedPairs(value) {
405
+ if (!Array.isArray(value) || value.length < 1 || value.some((pair) => !pair || typeof pair !== 'object' ||
406
+ Object.keys(pair).length !== 3 || !isLowercaseSha256(pair.origin_sha256) || !isLowercaseSha256(pair.user_sha256) || !isLowercaseSha256(pair.assistant_sha256))) {
407
+ throw new TypeError('expectedPairs must be a non-empty SHA-256 chain');
408
+ }
409
+ }
410
+
411
+ function isLowercaseSha256(value) { return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); }
@@ -1,4 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { once } from 'node:events';
2
4
  import { existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
5
  import { tmpdir } from 'node:os';
4
6
  import { join } from 'node:path';
@@ -9,10 +11,11 @@ import {
9
11
  AUDITOR_CONTEXT_SCHEMA,
10
12
  deriveAuditorFreshnessExpectation,
11
13
  hashAuditorBody,
14
+ readCompletedPairProjection,
12
15
  readAuditorContext,
13
16
  } from './auditor-context.mjs';
14
17
 
15
- function withDb(fn, { version = 8 } = {}) {
18
+ function withDb(fn, { version = 9 } = {}) {
16
19
  const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-context-'));
17
20
  const path = join(dir, 'throughline.db');
18
21
  const db = new DatabaseSync(path);
@@ -279,6 +282,118 @@ test('readAuditorContext: Codex freshness uses exact origin and both pair hashes
279
282
  });
280
283
  });
281
284
 
285
+ test('readCompletedPairProjection: ordered chain matches each DB pair once and preserves bounded identities', () => {
286
+ withDb(({ db, path }) => {
287
+ const pairs = [
288
+ { originSessionId: 'origin-a', turnNumber: 1, user: 'first user', assistant: 'first answer' },
289
+ { originSessionId: 'origin-b', turnNumber: 2, user: 'second user', assistant: 'second answer' },
290
+ ];
291
+ seedSession(db, { pairs });
292
+ db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)')
293
+ .run('session-1', 'extra', 3, 'user', 'not completed by chain', 10);
294
+ const expectedPairs = pairs.map((pair) => ({
295
+ origin_sha256: hashAuditorBody(pair.originSessionId), user_sha256: hashAuditorBody(pair.user), assistant_sha256: hashAuditorBody(pair.assistant),
296
+ }));
297
+ const result = readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs, maxBodyChars: 20, maxTotalChars: 0 });
298
+ assert.equal(result.status, 'fresh');
299
+ assert.equal(result.turns.length, 2);
300
+ assert.ok(result.turns.every((turn) => turn.truncated && turn.user === '' && turn.assistant === ''));
301
+ assert.deepEqual(result.turns.map((turn) => turn.origin_sha256), expectedPairs.map((pair) => pair.origin_sha256));
302
+ });
303
+ });
304
+
305
+ test('readCompletedPairProjection: missing or mismatched pair returns pending without partial bodies', () => {
306
+ withDb(({ db, path }) => {
307
+ const pairs = [
308
+ { originSessionId: 'origin-a', turnNumber: 1, user: 'first user', assistant: 'first answer' },
309
+ { originSessionId: 'origin-b', turnNumber: 2, user: 'second user', assistant: 'second answer' },
310
+ ];
311
+ seedSession(db, { pairs });
312
+ const expectedPairs = [
313
+ { origin_sha256: hashAuditorBody(pairs[0].originSessionId), user_sha256: hashAuditorBody(pairs[0].user), assistant_sha256: hashAuditorBody(pairs[0].assistant) },
314
+ { origin_sha256: hashAuditorBody(pairs[1].originSessionId), user_sha256: hashAuditorBody(pairs[1].user), assistant_sha256: hashAuditorBody('different') },
315
+ ];
316
+ assert.deepEqual(readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs }), {
317
+ status: 'pending', reason: 'pair_not_found', turns: [],
318
+ });
319
+ });
320
+ });
321
+
322
+ test('readCompletedPairProjection: missing session is pending without any body', () => {
323
+ withDb(({ path }) => {
324
+ const expectedPairs = [{
325
+ origin_sha256: hashAuditorBody('origin'), user_sha256: hashAuditorBody('user'), assistant_sha256: hashAuditorBody('assistant'),
326
+ }];
327
+ assert.deepEqual(readCompletedPairProjection({ dbPath: path, sessionId: 'missing-session', projectRoot: '/repo', expectedPairs }), {
328
+ status: 'pending', reason: 'session_not_found', turns: [],
329
+ });
330
+ });
331
+ });
332
+
333
+ test('readCompletedPairProjection: bounded wait内にwriter lockが解放されれば同じpairを読む', async () => {
334
+ const dir = mkdtempSync(join(tmpdir(), 'tl-observer-read-busy-'));
335
+ const path = join(dir, 'throughline.db');
336
+ const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'user', assistant: 'answer' };
337
+ const expectedPairs = [{
338
+ origin_sha256: hashAuditorBody(pair.originSessionId),
339
+ user_sha256: hashAuditorBody(pair.user),
340
+ assistant_sha256: hashAuditorBody(pair.assistant),
341
+ }];
342
+ const db = new DatabaseSync(path);
343
+ db.exec(`
344
+ PRAGMA journal_mode = DELETE;
345
+ PRAGMA user_version = 9;
346
+ CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
347
+ CREATE TABLE bodies (
348
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
349
+ session_id TEXT NOT NULL,
350
+ origin_session_id TEXT NOT NULL,
351
+ turn_number INTEGER NOT NULL,
352
+ role TEXT NOT NULL,
353
+ text TEXT NOT NULL,
354
+ created_at INTEGER NOT NULL
355
+ );
356
+ `);
357
+ seedSession(db, { pairs: [pair] });
358
+ db.close();
359
+ const writer = spawn(process.execPath, ['--input-type=module', '-e', `
360
+ import { DatabaseSync } from 'node:sqlite';
361
+ const db = new DatabaseSync(process.argv[1]);
362
+ db.exec('BEGIN EXCLUSIVE');
363
+ process.stdout.write('ready\\n');
364
+ setTimeout(() => { db.exec('ROLLBACK'); db.close(); }, 200);
365
+ `, path], { stdio: ['ignore', 'pipe', 'pipe'] });
366
+ const exited = once(writer, 'exit');
367
+ try {
368
+ await once(writer.stdout, 'data');
369
+ const startedAt = Date.now();
370
+ const result = readCompletedPairProjection({
371
+ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs,
372
+ });
373
+ assert.equal(result.status, 'fresh');
374
+ assert.deepEqual(result.turns.map((turn) => turn.origin_sha256), expectedPairs.map((entry) => entry.origin_sha256));
375
+ assert.ok(Date.now() - startedAt >= 100);
376
+ const [code] = await exited;
377
+ assert.equal(code, 0);
378
+ } finally {
379
+ if (writer.exitCode === null) writer.kill('SIGTERM');
380
+ rmSync(dir, { recursive: true, force: true });
381
+ }
382
+ });
383
+
384
+ test('readCompletedPairProjection: schema and project mismatch are hard failures', () => {
385
+ const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'user', assistant: 'answer' };
386
+ const expectedPairs = [{ origin_sha256: hashAuditorBody(pair.originSessionId), user_sha256: hashAuditorBody(pair.user), assistant_sha256: hashAuditorBody(pair.assistant) }];
387
+ withDb(({ db, path }) => {
388
+ seedSession(db, { projectPath: '/other', pairs: [pair] });
389
+ assert.throws(() => readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs }), (error) => error.code === 'E_AUDITOR_CONTEXT_PROJECT');
390
+ });
391
+ withDb(({ db, path }) => {
392
+ seedSession(db, { pairs: [pair] });
393
+ assert.throws(() => readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs }), (error) => error.code === 'E_AUDITOR_CONTEXT_SCHEMA');
394
+ }, { version: 7 });
395
+ });
396
+
282
397
  function snapshotSqliteFiles(path) {
283
398
  return [path, `${path}-wal`, `${path}-shm`].map((file) => {
284
399
  if (!existsSync(file)) return { file, exists: false };
package/src/baton.mjs CHANGED
@@ -43,16 +43,29 @@ export function writeBaton(db, { projectPath, sessionId, now = Date.now() }) {
43
43
  /**
44
44
  * 同 project_path のバトンを読み出して削除する (atomic)。
45
45
  *
46
+ * 適格性は `bornAt`(= 消費者セッションの誕生時刻)基準で判定する:
47
+ * age = bornAt - baton.created_at
48
+ * - age < 0 : バトンは消費者セッションより後に書かれた(本来の後継は
49
+ * その後に生まれる別セッション)→ **消さずに残し** skip。
50
+ * 二相ハンドオフでは消費が最初のプロンプト時点まで遅延するため、
51
+ * 「自分より後に書かれたバトン」を走行中セッションが横取りしない
52
+ * ためのガード。
53
+ * - age > ttlMs : TTL 超過。従来どおり削除して破棄。
54
+ * - それ以外 : 削除して sessionId を返す。
55
+ * `bornAt` 省略時は now と同値(= 従来の SessionStart 即時消費と同じ判定)。
56
+ *
46
57
  * 戻り値:
47
- * - { sessionId, ageMs } : バトン存在 かつ TTL 以内
48
- * - { sessionId: null, skipReason: 'expired', ageMs } : TTL 超過で破棄
49
- * - { sessionId: null, skipReason: 'missing' } : バトン無し
58
+ * - { sessionId, ageMs } : バトン存在 かつ適格
59
+ * - { sessionId: null, skipReason: 'expired', ageMs } : TTL 超過で破棄
60
+ * - { sessionId: null, skipReason: 'future_baton', ageMs } : 消費者誕生より後の
61
+ * バトン(残置)
62
+ * - { sessionId: null, skipReason: 'missing' } : バトン無し
50
63
  *
51
64
  * @param {import('node:sqlite').DatabaseSync} db
52
- * @param {{ projectPath: string, now?: number, ttlMs?: number }} params
53
- * @returns {{ sessionId: string | null, ageMs?: number, skipReason?: 'expired' | 'missing' }}
65
+ * @param {{ projectPath: string, now?: number, bornAt?: number, ttlMs?: number }} params
66
+ * @returns {{ sessionId: string | null, ageMs?: number, skipReason?: 'expired' | 'missing' | 'future_baton' }}
54
67
  */
55
- export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_TTL_MS }) {
68
+ export function consumeBaton(db, { projectPath, now = Date.now(), bornAt = now, ttlMs = BATON_TTL_MS }) {
56
69
  db.exec('BEGIN IMMEDIATE');
57
70
  try {
58
71
  // Windows 互換: ドライブレターの大小差を吸収するため COLLATE NOCASE
@@ -67,10 +80,17 @@ export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_
67
80
  return { sessionId: null, skipReason: 'missing' };
68
81
  }
69
82
 
83
+ const ageMs = bornAt - row.created_at;
84
+
85
+ if (ageMs < 0) {
86
+ // 消費者より未来のバトンは本来の後継のために残す(削除しない)
87
+ db.exec('COMMIT');
88
+ return { sessionId: null, skipReason: 'future_baton', ageMs };
89
+ }
90
+
70
91
  db.prepare('DELETE FROM handoff_batons WHERE project_path = ? COLLATE NOCASE').run(
71
92
  projectPath,
72
93
  );
73
- const ageMs = now - row.created_at;
74
94
 
75
95
  if (ageMs > ttlMs) {
76
96
  db.exec('COMMIT');
@@ -105,3 +105,47 @@ test('consumeBaton: returns no memoText property in v8 (memo column dropped)', (
105
105
  assert.equal(result.sessionId, 'S1');
106
106
  assert.equal('memoText' in result, false, 'memoText property should be removed in v8');
107
107
  });
108
+
109
+ test('consumeBaton: bornAt basis — session born after baton write consumes it', () => {
110
+ const db = makeDb();
111
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
112
+ // セッション誕生 2000、消費 (初回プロンプト) はずっと後の 50 分後でも、
113
+ // TTL は誕生時刻基準なので age = 1 秒で有効
114
+ const result = consumeBaton(db, {
115
+ projectPath: '/proj',
116
+ now: 1000 + 50 * 60 * 1000,
117
+ bornAt: 2000,
118
+ });
119
+ assert.equal(result.sessionId, 'S1');
120
+ assert.equal(result.ageMs, 1000);
121
+ assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
122
+ });
123
+
124
+ test('consumeBaton: bornAt basis — TTL measured from birth, not from consumption time', () => {
125
+ const db = makeDb();
126
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
127
+ // 誕生が /tl の 2 時間後 → 初回プロンプトが即時でも expired
128
+ const result = consumeBaton(db, {
129
+ projectPath: '/proj',
130
+ now: 1000 + 2 * 60 * 60 * 1000 + 5000,
131
+ bornAt: 1000 + 2 * 60 * 60 * 1000,
132
+ });
133
+ assert.equal(result.sessionId, null);
134
+ assert.equal(result.skipReason, 'expired');
135
+ assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0, 'expired は従来どおり削除');
136
+ });
137
+
138
+ test('consumeBaton: future_baton — baton written after consumer birth is left in place', () => {
139
+ const db = makeDb();
140
+ // 走行中セッション (誕生 1000) が初回プロンプト消費を試みる時、
141
+ // 誕生後 (5000) に書かれた他セッションのバトンは本来の後継のために残す
142
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S-later', now: 5000 });
143
+ const result = consumeBaton(db, { projectPath: '/proj', now: 6000, bornAt: 1000 });
144
+ assert.equal(result.sessionId, null);
145
+ assert.equal(result.skipReason, 'future_baton');
146
+ assert.ok(result.ageMs < 0);
147
+
148
+ const rows = db.prepare('SELECT session_id FROM handoff_batons').all();
149
+ assert.equal(rows.length, 1, 'future baton must NOT be deleted');
150
+ assert.equal(rows[0].session_id, 'S-later');
151
+ });
@@ -0,0 +1,9 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function normalizeAuditorBody(value) {
4
+ return String(value ?? '').normalize('NFC').replace(/\r\n?/g, '\n').trim();
5
+ }
6
+
7
+ export function hashAuditorBody(value) {
8
+ return createHash('sha256').update(normalizeAuditorBody(value), 'utf8').digest('hex');
9
+ }
@@ -20,7 +20,7 @@ function makeDb({ originSessionId = 'origin-1' } = {}) {
20
20
  const path = join(dir, 'throughline.db');
21
21
  const db = new DatabaseSync(path);
22
22
  db.exec(`
23
- PRAGMA user_version = 8;
23
+ PRAGMA user_version = 9;
24
24
  CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
25
25
  CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL);
26
26
  INSERT INTO sessions VALUES ('session-1', '/repo');
@@ -17,6 +17,7 @@ const REQUIRED_DATABASE_COLUMNS = {
17
17
  bodies: ['id', 'session_id', 'origin_session_id', 'turn_number', 'role', 'text', 'token_count', 'created_at'],
18
18
  details: ['id', 'session_id', 'turn_number', 'tool_name', 'input_text', 'output_text', 'token_count', 'created_at', 'origin_session_id', 'kind', 'source_id'],
19
19
  handoff_batons: ['project_path', 'session_id', 'created_at'],
20
+ pending_handoffs: ['session_id', 'project_path', 'source', 'auto_predecessor_id', 'created_at'],
20
21
  };
21
22
  const REQUIRED_DATABASE_INDEXES = ['uq_skeletons_turn_v3', 'uq_details_source'];
22
23
  const REQUIRED_INDEX_SHAPES = {
@@ -163,9 +164,11 @@ export function inspectFactoryHooks({ codexHome = defaultCodexHome(), readHooks
163
164
  return {
164
165
  status: values.includes('not_ready')
165
166
  ? 'not_ready'
166
- : values.includes('ready')
167
+ : values.includes('unverified')
167
168
  ? 'unverified'
168
- : 'not_applicable',
169
+ : values.includes('ready')
170
+ ? 'ready'
171
+ : 'not_applicable',
169
172
  reason: 'hooks_inspected',
170
173
  events,
171
174
  };
@@ -116,7 +116,7 @@ test('factory-diagnostics DB inspection rejects version-only fake schema', () =>
116
116
  const dbPath = join(dir, 'throughline.db');
117
117
  try {
118
118
  const db = new DatabaseSync(dbPath);
119
- db.exec('PRAGMA user_version = 8; CREATE TABLE sessions (session_id TEXT)');
119
+ db.exec('PRAGMA user_version = 9; CREATE TABLE sessions (session_id TEXT)');
120
120
  db.close();
121
121
  assert.equal(inspectFactoryDatabase({ dbPath }).status, 'not_ready');
122
122
  } finally {
@@ -168,9 +168,34 @@ test('factory-diagnostics hook inspection rejects corrupt and false-ready shapes
168
168
  assert.equal(malformed.status, 'not_ready');
169
169
  });
170
170
 
171
+ test('factory-diagnostics hook inspection summarizes every canonical ready event as ready', () => {
172
+ const result = inspectFactoryHooks({
173
+ readHooks: () => ({
174
+ configExists: true,
175
+ configReadable: true,
176
+ hooksExists: true,
177
+ hooksReadable: true,
178
+ featureEnabled: true,
179
+ expectedPromptCommand: 'prompt',
180
+ expectedPostToolUseCommand: 'post',
181
+ expectedStopCommand: 'stop',
182
+ managedPromptHooks: [{ type: 'command', command: 'prompt', timeoutSec: 30, async: false }],
183
+ legacyManagedPromptHooks: [],
184
+ managedPostToolUseHooks: [{ type: 'command', command: 'post', timeoutSec: 30, async: false }],
185
+ legacyManagedPostToolUseHooks: [],
186
+ managedStopHooks: [{ type: 'command', command: 'stop', timeoutSec: 300, async: false }],
187
+ legacyManagedStopHooks: [],
188
+ }),
189
+ });
190
+
191
+ assert.deepEqual(result.events, { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' });
192
+ assert.equal(result.status, 'ready');
193
+ assert.equal(result.reason, 'hooks_inspected');
194
+ });
195
+
171
196
  function createFactorySchema(db) {
172
197
  db.exec(`
173
- PRAGMA user_version = 8;
198
+ PRAGMA user_version = 9;
174
199
  CREATE TABLE sessions (
175
200
  session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, status TEXT NOT NULL,
176
201
  created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, merged_into TEXT
@@ -192,6 +217,10 @@ function createFactorySchema(db) {
192
217
  CREATE TABLE handoff_batons (
193
218
  project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL
194
219
  );
220
+ CREATE TABLE pending_handoffs (
221
+ session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, source TEXT,
222
+ auto_predecessor_id TEXT, created_at INTEGER NOT NULL
223
+ );
195
224
  CREATE UNIQUE INDEX uq_skeletons_turn_v3
196
225
  ON skeletons(session_id, origin_session_id, turn_number, role);
197
226
  CREATE UNIQUE INDEX uq_details_source
@@ -0,0 +1,73 @@
1
+ import { readObserverTurnPage, OBSERVER_READ_SCHEMA } from '../observer-turn-feed.mjs';
2
+
3
+ const ERRORS = Object.freeze({
4
+ args: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_ARGS', message: 'invalid observer-read arguments' },
5
+ input: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_INPUT', message: 'observer read input is invalid' },
6
+ schema: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_DB_SCHEMA', message: 'observer read database schema is unsupported' },
7
+ project: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_DB_PROJECT', message: 'observer read database project does not match' },
8
+ io: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_DB_IO', message: 'observer read database could not be read' },
9
+ internal: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_INTERNAL', message: 'observer read failed' },
10
+ });
11
+
12
+ export function parseArgs(argv = []) {
13
+ const out = { projectPath: null, afterCursor: undefined, throughCursor: undefined, pageToken: undefined, limit: undefined, json: false };
14
+ const seen = new Set();
15
+ for (let index = 0; index < argv.length; index++) {
16
+ const arg = argv[index];
17
+ if (arg === '--json') {
18
+ if (seen.has(arg)) throw new TypeError('duplicate option');
19
+ seen.add(arg); out.json = true; continue;
20
+ }
21
+ if (!['--project', '--after-cursor', '--through-cursor', '--page-token', '--limit'].includes(arg) || seen.has(arg)) {
22
+ throw new TypeError('invalid option');
23
+ }
24
+ const value = argv[++index];
25
+ if (!value || value.startsWith('-')) throw new TypeError('missing option value');
26
+ seen.add(arg);
27
+ if (arg === '--project') out.projectPath = value;
28
+ else if (arg === '--after-cursor') out.afterCursor = value;
29
+ else if (arg === '--through-cursor') out.throughCursor = value;
30
+ else if (arg === '--page-token') out.pageToken = value;
31
+ else out.limit = parseLimit(value);
32
+ }
33
+ if (!out.json || !out.projectPath || (out.pageToken !== undefined && (out.afterCursor === undefined || out.throughCursor === undefined))) {
34
+ throw new TypeError('missing required option');
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function run(argv = [], { read = readObserverTurnPage, stdout = process.stdout, stderr = process.stderr } = {}) {
40
+ let args;
41
+ try { args = parseArgs(argv); } catch { writeJson(stderr, ERRORS.args); return 1; }
42
+ try {
43
+ const result = read({
44
+ projectPath: args.projectPath,
45
+ ...(args.afterCursor === undefined ? {} : { afterCursor: args.afterCursor }),
46
+ ...(args.throughCursor === undefined ? {} : { throughCursor: args.throughCursor }),
47
+ ...(args.pageToken === undefined ? {} : { pageToken: args.pageToken }),
48
+ ...(args.limit === undefined ? {} : { limit: args.limit }),
49
+ });
50
+ writeJson(stdout, result);
51
+ return 0;
52
+ } catch (error) {
53
+ writeJson(stderr, mapError(error));
54
+ return 1;
55
+ }
56
+ }
57
+
58
+ function parseLimit(value) {
59
+ if (!/^\d+$/.test(value)) throw new TypeError('invalid limit');
60
+ const limit = Number(value);
61
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new TypeError('invalid limit');
62
+ return limit;
63
+ }
64
+
65
+ function mapError(error) {
66
+ if (error?.code === 'E_AUDITOR_CONTEXT_SCHEMA') return ERRORS.schema;
67
+ if (error?.code === 'E_AUDITOR_CONTEXT_PROJECT') return ERRORS.project;
68
+ if (['E_AUDITOR_CONTEXT_DB_OPEN', 'E_AUDITOR_CONTEXT_QUERY'].includes(error?.code)) return ERRORS.io;
69
+ if (error instanceof TypeError) return ERRORS.input;
70
+ return ERRORS.internal;
71
+ }
72
+
73
+ function writeJson(stream, value) { stream.write(`${JSON.stringify(value)}\n`); }
@@ -0,0 +1,93 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { join } from 'node:path';
7
+ import test from 'node:test';
8
+ import { OBSERVER_READ_SCHEMA } from '../observer-turn-feed.mjs';
9
+ import { parseArgs, run } from './observer-read.mjs';
10
+
11
+ const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
12
+ const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
13
+ const FIXED = { schema: OBSERVER_READ_SCHEMA, status: 'snapshot', turns: [], page: { complete: true, nextToken: null } };
14
+
15
+ function streams() {
16
+ return { stdout: { text: '', write(value) { this.text += value; } }, stderr: { text: '', write(value) { this.text += value; } } };
17
+ }
18
+
19
+ test('observer-read parses each supported option once and delegates validation to read library', () => {
20
+ assert.deepEqual(parseArgs(['--project', '/repo', '--after-cursor', 'after', '--through-cursor', 'through', '--page-token', 'page', '--limit', '100', '--json']), {
21
+ projectPath: '/repo', afterCursor: 'after', throughCursor: 'through', pageToken: 'page', limit: 100, json: true,
22
+ });
23
+ const io = streams(); let input;
24
+ assert.equal(run(['--project', '/repo', '--json'], { ...io, read(value) { input = value; return FIXED; } }), 0);
25
+ assert.deepEqual(input, { projectPath: '/repo' });
26
+ assert.deepEqual(JSON.parse(io.stdout.text), FIXED);
27
+ assert.equal(io.stderr.text, '');
28
+ });
29
+
30
+ test('observer-read rejects duplicate, missing, positional, and invalid pagination arguments with fixed args error', () => {
31
+ for (const argv of [
32
+ ['--project', '/repo', '--project', '/repo', '--json'], ['--project', '/repo', '--json', '--json'],
33
+ ['--project', '/repo', '--page-token', 'p', '--json'], ['--project', '/repo', '--limit', '0', '--json'],
34
+ ['--project', '/repo', 'extra', '--json'], ['--json'],
35
+ ]) {
36
+ const io = streams();
37
+ assert.equal(run(argv, { ...io }), 1);
38
+ assert.equal(io.stdout.text, '');
39
+ assert.deepEqual(JSON.parse(io.stderr.text), { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_ARGS', message: 'invalid observer-read arguments' });
40
+ }
41
+ });
42
+
43
+ test('observer-read keeps known states successful and maps hard failures without leakage', () => {
44
+ for (const status of ['snapshot', 'delta', 'thread_switched', 'host_switched', 'resync_required', 'projection_pending', 'ambiguous_parent']) {
45
+ const io = streams();
46
+ assert.equal(run(['--project', '/repo', '--json'], { ...io, read: () => ({ ...FIXED, status }) }), 0);
47
+ assert.equal(JSON.parse(io.stdout.text).status, status);
48
+ assert.equal(io.stderr.text, '');
49
+ }
50
+ for (const [error, code] of [
51
+ [new TypeError('/private/cursor body'), 'E_OBSERVER_READ_INPUT'],
52
+ [Object.assign(new Error('schema'), { code: 'E_AUDITOR_CONTEXT_SCHEMA' }), 'E_OBSERVER_READ_DB_SCHEMA'],
53
+ [Object.assign(new Error('project'), { code: 'E_AUDITOR_CONTEXT_PROJECT' }), 'E_OBSERVER_READ_DB_PROJECT'],
54
+ [Object.assign(new Error('io'), { code: 'E_AUDITOR_CONTEXT_QUERY' }), 'E_OBSERVER_READ_DB_IO'],
55
+ [new Error('secret /private cursor'), 'E_OBSERVER_READ_INTERNAL'],
56
+ ]) {
57
+ const io = streams();
58
+ assert.equal(run(['--project', '/repo', '--json'], { ...io, read: () => { throw error; } }), 1);
59
+ assert.equal(io.stdout.text, '');
60
+ assert.equal(JSON.parse(io.stderr.text).code, code);
61
+ assert.doesNotMatch(io.stderr.text, /private|cursor|secret/i);
62
+ }
63
+ });
64
+
65
+ test('observer-read bin dispatch and help advertise JSON-only command', () => {
66
+ const help = spawnSync(process.execPath, [BIN_PATH, '--help'], { cwd: REPO_ROOT, encoding: 'utf8' });
67
+ assert.equal(help.status, 0, help.stderr);
68
+ assert.match(help.stdout, /throughline observer-read --project <absolute-directory> --json/);
69
+ });
70
+
71
+ test('observer-read bin dispatch returns an empty snapshot from an isolated environment', async () => {
72
+ const root = await mkdtemp(join(tmpdir(), 'throughline-observer-read-'));
73
+ const home = join(root, 'home');
74
+ const state = join(root, 'state');
75
+ const codexHome = join(root, 'codex');
76
+ const project = join(root, 'project');
77
+ try {
78
+ await Promise.all([mkdir(home), mkdir(state), mkdir(codexHome), mkdir(project)]);
79
+ const result = spawnSync(process.execPath, [BIN_PATH, 'observer-read', '--project', project, '--json'], {
80
+ cwd: REPO_ROOT,
81
+ encoding: 'utf8',
82
+ env: { ...process.env, HOME: home, USERPROFILE: home, XDG_STATE_HOME: state, CODEX_HOME: codexHome },
83
+ });
84
+ assert.equal(result.status, 0, result.stderr);
85
+ assert.equal(result.stderr, '');
86
+ const page = JSON.parse(result.stdout);
87
+ assert.equal(page.schema, OBSERVER_READ_SCHEMA);
88
+ assert.equal(page.status, 'snapshot');
89
+ assert.deepEqual(page.turns, []);
90
+ } finally {
91
+ await rm(root, { recursive: true, force: true });
92
+ }
93
+ });