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
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { DatabaseSync } from 'node:sqlite';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { readCompletedTurnReceiptSnapshot } from './completed-turn-receipts.mjs';
16
17
 
17
18
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
19
 
@@ -183,7 +184,7 @@ test('prompt-submit: non-baton prompt does not write any baton', () => {
183
184
  }
184
185
  });
185
186
 
186
- test('session-start subprocess consumes baton and injects inherited resume context', () => {
187
+ test('two-phase: session-start registers intent only; first prompt consumes baton and injects', () => {
187
188
  const home = makeTempHome();
188
189
  const project = makeTempProject();
189
190
  try {
@@ -210,6 +211,7 @@ test('session-start subprocess consumes baton and injects inherited resume conte
210
211
  ).run();
211
212
  db.close();
212
213
 
214
+ // 第一相: SessionStart は intent 登録のみ。merge も注入もせず、baton も残る。
213
215
  const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
214
216
  home,
215
217
  cwd: project,
@@ -219,17 +221,127 @@ test('session-start subprocess consumes baton and injects inherited resume conte
219
221
  source: 'startup',
220
222
  }),
221
223
  });
222
-
223
224
  assert.equal(started.status, 0, started.stderr);
224
- assert.match(started.stdout, /old assistant body/);
225
+ assert.equal(started.stdout, '', 'SessionStart must not inject anything');
226
+
227
+ const mid = openDb(home);
228
+ assert.equal(
229
+ mid.prepare("SELECT merged_into FROM sessions WHERE session_id = 'old-session'").get()
230
+ .merged_into,
231
+ null,
232
+ 'no merge at SessionStart',
233
+ );
234
+ assert.equal(mid.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 1, 'baton kept');
235
+ assert.equal(
236
+ mid.prepare("SELECT COUNT(*) AS c FROM pending_handoffs WHERE session_id = 'new-session'").get().c,
237
+ 1,
238
+ 'pending intent registered',
239
+ );
240
+ mid.close();
241
+
242
+ // 第二相: 初回プロンプト (= 実体の証明) で consume + merge + 注入。
243
+ const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
244
+ home,
245
+ cwd: project,
246
+ input: JSON.stringify({
247
+ session_id: 'new-session',
248
+ cwd: project,
249
+ prompt: 'continue the work',
250
+ }),
251
+ });
252
+ assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
253
+ assert.match(firstPrompt.stdout, /old assistant body/);
254
+
255
+ const after = openDb(home);
256
+ assert.equal(
257
+ after.prepare("SELECT merged_into FROM sessions WHERE session_id = 'old-session'").get()
258
+ .merged_into,
259
+ 'new-session',
260
+ );
261
+ assert.equal(after.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
262
+ assert.equal(after.prepare('SELECT COUNT(*) AS c FROM pending_handoffs').get().c, 0);
263
+ after.close();
264
+
265
+ // 2 回目のプロンプトでは再注入しない (pending 消費済み)
266
+ const secondPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
267
+ home,
268
+ cwd: project,
269
+ input: JSON.stringify({
270
+ session_id: 'new-session',
271
+ cwd: project,
272
+ prompt: 'and then?',
273
+ }),
274
+ });
275
+ assert.equal(secondPrompt.status, 0, secondPrompt.stderr);
276
+ assert.equal(secondPrompt.stdout, '', 'no duplicate injection on later prompts');
277
+ } finally {
278
+ rmSync(project, { recursive: true, force: true });
279
+ rmSync(home, { recursive: true, force: true });
280
+ }
281
+ });
282
+
283
+ test('two-phase GHOST regression: first-arriving promptless SessionStart cannot steal the baton', () => {
284
+ // 2026-07-17 incident (ADR 0014) の再現形:
285
+ // 同一 project で幽霊 SessionStart が実体より数百 ms 先に発火しても、
286
+ // プロンプトを発火しない限りバトンも記憶も奪えない。
287
+ const home = makeTempHome();
288
+ const project = makeTempProject();
289
+ try {
290
+ const baton = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
291
+ home,
292
+ cwd: project,
293
+ input: JSON.stringify({ session_id: 'predecessor', cwd: project, prompt: '/tl' }),
294
+ });
295
+ assert.equal(baton.status, 0, baton.stderr);
296
+
297
+ const db = openDb(home);
298
+ db.prepare(
299
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
300
+ VALUES ('predecessor', ?, 'active', 1, 1)`,
301
+ ).run(project);
302
+ db.prepare(
303
+ `INSERT INTO bodies
304
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
305
+ VALUES ('predecessor', 'predecessor', 1, 'assistant', 'precious memory', 4, 2)`,
306
+ ).run();
307
+ db.close();
308
+
309
+ // 幽霊が先着 (488ms 差の再現 — 順序だけが本質)
310
+ const ghost = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
311
+ home,
312
+ cwd: project,
313
+ input: JSON.stringify({ session_id: 'ghost-session', cwd: project, source: 'startup' }),
314
+ });
315
+ assert.equal(ghost.status, 0, ghost.stderr);
316
+
317
+ // 実体が後着
318
+ const real = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
319
+ home,
320
+ cwd: project,
321
+ input: JSON.stringify({ session_id: 'real-session', cwd: project, source: 'startup' }),
322
+ });
323
+ assert.equal(real.status, 0, real.stderr);
324
+
325
+ // 幽霊はプロンプトを発火しない。実体の初回プロンプトが記憶を受け取る。
326
+ const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
327
+ home,
328
+ cwd: project,
329
+ input: JSON.stringify({ session_id: 'real-session', cwd: project, prompt: '続きをやろう' }),
330
+ });
331
+ assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
332
+ assert.match(firstPrompt.stdout, /precious memory/, 'real session receives the memory');
225
333
 
226
334
  const after = openDb(home);
227
- const old = after
228
- .prepare("SELECT merged_into FROM sessions WHERE session_id = 'old-session'")
335
+ assert.equal(
336
+ after.prepare("SELECT merged_into FROM sessions WHERE session_id = 'predecessor'").get()
337
+ .merged_into,
338
+ 'real-session',
339
+ 'memory merged into the REAL session, not the ghost',
340
+ );
341
+ const ghostBodies = after
342
+ .prepare("SELECT COUNT(*) AS c FROM bodies WHERE session_id = 'ghost-session'")
229
343
  .get();
230
- const batons = after.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get();
231
- assert.equal(old.merged_into, 'new-session');
232
- assert.equal(batons.c, 0);
344
+ assert.equal(ghostBodies.c, 0, 'ghost holds nothing');
233
345
  after.close();
234
346
  } finally {
235
347
  rmSync(project, { recursive: true, force: true });
@@ -237,7 +349,53 @@ test('session-start subprocess consumes baton and injects inherited resume conte
237
349
  }
238
350
  });
239
351
 
240
- test('session-start subprocess does not inject context when no baton exists', () => {
352
+ test('two-phase: a running session does not steal a baton written after its birth', () => {
353
+ const home = makeTempHome();
354
+ const project = makeTempProject();
355
+ try {
356
+ // セッション B が誕生して初回プロンプトも済んでいる (pending 消費済み)
357
+ const startB = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
358
+ home,
359
+ cwd: project,
360
+ input: JSON.stringify({ session_id: 'running-B', cwd: project, source: 'startup' }),
361
+ });
362
+ assert.equal(startB.status, 0, startB.stderr);
363
+ const promptB1 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
364
+ home,
365
+ cwd: project,
366
+ input: JSON.stringify({ session_id: 'running-B', cwd: project, prompt: 'work work' }),
367
+ });
368
+ assert.equal(promptB1.status, 0, promptB1.stderr);
369
+
370
+ // その後、別セッション A が /tl → 次の新規セッションへのバトンを書く
371
+ const tl = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
372
+ home,
373
+ cwd: project,
374
+ input: JSON.stringify({ session_id: 'session-A', cwd: project, prompt: '/tl' }),
375
+ });
376
+ assert.equal(tl.status, 0, tl.stderr);
377
+
378
+ // B の 2 発目のプロンプトはバトンを奪えない (pending 無し = ハンドオフ判定なし)
379
+ const promptB2 = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
380
+ home,
381
+ cwd: project,
382
+ input: JSON.stringify({ session_id: 'running-B', cwd: project, prompt: 'more work' }),
383
+ });
384
+ assert.equal(promptB2.status, 0, promptB2.stderr);
385
+ assert.equal(promptB2.stdout, '');
386
+
387
+ const db = openDb(home);
388
+ const rows = db.prepare('SELECT session_id FROM handoff_batons').all();
389
+ assert.equal(rows.length, 1, 'baton must remain for the true successor');
390
+ assert.equal(rows[0].session_id, 'session-A');
391
+ db.close();
392
+ } finally {
393
+ rmSync(project, { recursive: true, force: true });
394
+ rmSync(home, { recursive: true, force: true });
395
+ }
396
+ });
397
+
398
+ test('session-start subprocess registers session row and stays silent without a baton', () => {
241
399
  const home = makeTempHome();
242
400
  const project = makeTempProject();
243
401
  try {
@@ -260,6 +418,10 @@ test('session-start subprocess does not inject context when no baton exists', ()
260
418
  .get();
261
419
  assert.equal(row.session_id, 'new-session');
262
420
  assert.equal(row.project_path, project);
421
+ const pending = db
422
+ .prepare("SELECT COUNT(*) AS c FROM pending_handoffs WHERE session_id = 'new-session'")
423
+ .get();
424
+ assert.equal(pending.c, 1, 'pending intent registered even without baton');
263
425
  db.close();
264
426
  } finally {
265
427
  rmSync(project, { recursive: true, force: true });
@@ -333,6 +495,7 @@ test('process-turn subprocess stores L2 bodies and L3 details in an isolated DB'
333
495
  session_id: 'turn-session',
334
496
  cwd: project,
335
497
  transcript_path: transcriptPath,
498
+ last_assistant_message: 'check passed',
336
499
  }),
337
500
  });
338
501
 
@@ -388,7 +551,12 @@ test('process-turn subprocess backfills all completed logical turns from a multi
388
551
  const result = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
389
552
  home,
390
553
  cwd: project,
391
- input: JSON.stringify({ session_id: 'multi-turn-session', cwd: project, transcript_path: transcriptPath }),
554
+ input: JSON.stringify({
555
+ session_id: 'multi-turn-session',
556
+ cwd: project,
557
+ transcript_path: transcriptPath,
558
+ last_assistant_message: 'second answer',
559
+ }),
392
560
  });
393
561
  assert.equal(result.status, 0, result.stderr);
394
562
  const db = openDb(home);
@@ -401,6 +569,9 @@ test('process-turn subprocess backfills all completed logical turns from a multi
401
569
  { role: 'user', text: 'second request' },
402
570
  ],
403
571
  );
572
+ const receipts = readCompletedTurnReceiptSnapshot({ projectPath: project, env: childEnv(home) });
573
+ assert.equal(receipts.receipts.length, 2);
574
+ assert.deepEqual(receipts.receipts.map((entry) => entry.sequence), [1, 2]);
404
575
  db.close();
405
576
  } finally {
406
577
  rmSync(project, { recursive: true, force: true });
@@ -408,7 +579,7 @@ test('process-turn subprocess backfills all completed logical turns from a multi
408
579
  }
409
580
  });
410
581
 
411
- test('session-start backfills a derived predecessor transcript without a state file', {
582
+ test('first prompt backfills a derived predecessor transcript without a state file', {
412
583
  skip: process.platform === 'win32' ? 'Windowsはstate file transcriptPath fallback契約' : undefined,
413
584
  }, () => {
414
585
  const home = makeTempHome();
@@ -450,6 +621,15 @@ test('session-start backfills a derived predecessor transcript without a state f
450
621
  input: JSON.stringify({ session_id: 'new-session', cwd: project, source: 'startup' }),
451
622
  });
452
623
  assert.equal(started.status, 0, started.stderr);
624
+
625
+ // 二相ハンドオフ: backfill + merge は初回プロンプトで行われる
626
+ const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
627
+ home,
628
+ cwd: project,
629
+ input: JSON.stringify({ session_id: 'new-session', cwd: project, prompt: 'continue' }),
630
+ });
631
+ assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
632
+
453
633
  const after = openDb(home);
454
634
  assert.deepEqual(
455
635
  after
@@ -466,7 +646,7 @@ test('session-start backfills a derived predecessor transcript without a state f
466
646
  .split('\n')
467
647
  .filter((line) => line)
468
648
  .map((line) => JSON.parse(line));
469
- assert.ok(backfillLog.some((entry) => entry.hook === 'session-start'));
649
+ assert.ok(backfillLog.some((entry) => entry.hook === 'prompt-submit'));
470
650
  assert.equal(existsSync(join(home, '.throughline', 'state', `${predecessorId}.json`)), false);
471
651
  } finally {
472
652
  rmSync(project, { recursive: true, force: true });
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { resolveObserverTurnFeed } from './observer-turn-feed.mjs';
8
+
9
+ test('observer Codex projection: feed origin hash matches codex:<thread_id> DB identity', () => {
10
+ const root = mkdtempSync(join(tmpdir(), 'tl-observer-codex-projection-'));
11
+ const project = join(root, 'project');
12
+ const codexHome = join(root, 'codex');
13
+ const rolloutDir = join(codexHome, 'sessions', '2026', '07', '15');
14
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
15
+ const sessionId = `codex:${threadId}`;
16
+ const dbPath = join(root, 'throughline.db');
17
+ let db;
18
+ try {
19
+ mkdirSync(project);
20
+ mkdirSync(rolloutDir, { recursive: true });
21
+ const events = [
22
+ { type: 'session_meta', timestamp: '2026-07-15T00:00:00.000Z', payload: { id: threadId, cwd: project } },
23
+ { type: 'event_msg', timestamp: '2026-07-15T00:00:01.000Z', payload: { type: 'user_message', message: 'codex user' } },
24
+ { type: 'event_msg', timestamp: '2026-07-15T00:00:02.000Z', payload: { type: 'task_started' } },
25
+ { type: 'event_msg', timestamp: '2026-07-15T00:00:03.000Z', payload: { type: 'agent_message', message: 'codex answer' } },
26
+ { type: 'event_msg', timestamp: '2026-07-15T00:00:04.000Z', payload: { type: 'task_complete' } },
27
+ ];
28
+ writeFileSync(join(rolloutDir, `rollout-2026-07-15T00-00-00-${threadId}.jsonl`), events.map(JSON.stringify).join('\n'));
29
+
30
+ db = new DatabaseSync(dbPath);
31
+ db.exec(`PRAGMA user_version = 9;
32
+ CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
33
+ 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);`);
34
+ db.prepare('INSERT INTO sessions (session_id, project_path) VALUES (?, ?)').run(sessionId, project);
35
+ const insert = db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, 1, ?, ?, ?)');
36
+ insert.run(sessionId, sessionId, 'user', 'codex user', 1);
37
+ insert.run(sessionId, sessionId, 'assistant', 'codex answer', 2);
38
+
39
+ const result = resolveObserverTurnFeed({ projectPath: project, codexHome, dbPath });
40
+ assert.equal(result.status, 'snapshot');
41
+ assert.deepEqual(result.turns.map(({ user, assistant }) => ({ user, assistant })), [
42
+ { user: 'codex user', assistant: 'codex answer' },
43
+ ]);
44
+ assert.doesNotMatch(JSON.stringify(result), new RegExp(`${threadId}|codex:`));
45
+ } finally {
46
+ db?.close();
47
+ rmSync(root, { recursive: true, force: true });
48
+ }
49
+ });