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,10 +1,17 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
+ import { appendFileSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
4
7
  import {
5
8
  L2_WINDOW,
9
+ CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS,
10
+ CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS,
6
11
  countDistinctBodyTurns,
7
12
  pickOldestUnsummarizedTurn,
13
+ publishCapturedClaudeCompletionReceipt,
14
+ waitForClaudeStopTranscriptFlush,
8
15
  } from './turn-processor.mjs';
9
16
 
10
17
  function makeDb() {
@@ -54,6 +61,140 @@ test('L2_WINDOW is 20', () => {
54
61
  assert.equal(L2_WINDOW, 20);
55
62
  });
56
63
 
64
+ test('Claude Stop flush barrierはlatest userの遅延assistantを待ち、過去の同文answerを採用しない', async () => {
65
+ const root = mkdtempSync(join(tmpdir(), 'throughline-stop-flush-'));
66
+ const transcriptPath = join(root, 'transcript.jsonl');
67
+ const answer = 'same answer';
68
+ let elapsed = 0;
69
+ let waits = 0;
70
+ try {
71
+ writeFileSync(
72
+ transcriptPath,
73
+ [
74
+ { type: 'user', message: { role: 'user', content: 'old question' } },
75
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: answer }] } },
76
+ { type: 'user', message: { role: 'user', content: 'current question' } },
77
+ ].map((entry) => JSON.stringify(entry)).join('\n'),
78
+ 'utf8',
79
+ );
80
+ const result = await waitForClaudeStopTranscriptFlush({
81
+ transcriptPath,
82
+ lastAssistantMessage: answer,
83
+ timeoutMs: 100,
84
+ intervalMs: 10,
85
+ now: () => elapsed,
86
+ wait: async (milliseconds) => {
87
+ elapsed += milliseconds;
88
+ waits++;
89
+ if (waits === 1) {
90
+ appendFileSync(
91
+ transcriptPath,
92
+ `\n${JSON.stringify({
93
+ type: 'assistant',
94
+ message: { role: 'assistant', content: [{ type: 'text', text: answer }] },
95
+ })}`,
96
+ 'utf8',
97
+ );
98
+ }
99
+ },
100
+ });
101
+ assert.deepEqual(result, { status: 'ready', userTurnNumber: 2, assistantTurnNumber: 3 });
102
+ assert.equal(waits, 1, 'past identical answer must not satisfy the current user group');
103
+ } finally {
104
+ rmSync(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test('Claude Stop flush barrierはmarker不一致をdeadlineで明示失敗する', async () => {
109
+ let elapsed = 0;
110
+ await assert.rejects(
111
+ waitForClaudeStopTranscriptFlush({
112
+ transcriptPath: '/missing',
113
+ lastAssistantMessage: 'expected',
114
+ timeoutMs: 30,
115
+ intervalMs: 10,
116
+ readCompletion: () => ({ userTurnNumber: 4, assistantTurnNumber: null, assistantContent: null }),
117
+ now: () => elapsed,
118
+ wait: async (milliseconds) => { elapsed += milliseconds; },
119
+ }),
120
+ /not visible before deadline/,
121
+ );
122
+ assert.equal(elapsed, 30);
123
+ });
124
+
125
+ test('Claude Stop flush barrierはmarkerなし旧payloadをone-shot互換へ残す', async () => {
126
+ let reads = 0;
127
+ const result = await waitForClaudeStopTranscriptFlush({
128
+ transcriptPath: '/unused',
129
+ lastAssistantMessage: undefined,
130
+ readCompletion: () => { reads++; return null; },
131
+ });
132
+ assert.deepEqual(result, { status: 'marker_unavailable' });
133
+ assert.equal(reads, 0);
134
+ assert.equal(CLAUDE_STOP_TRANSCRIPT_FLUSH_TIMEOUT_MS, 2_000);
135
+ assert.equal(CLAUDE_STOP_TRANSCRIPT_FLUSH_INTERVAL_MS, 25);
136
+ });
137
+
138
+ test('publishCapturedClaudeCompletionReceipt: L2 capture済みpairをL1/L3より先にprivate receiptへ固定する', () => {
139
+ const db = makeDb();
140
+ const root = mkdtempSync(join(tmpdir(), 'throughline-turn-receipt-'));
141
+ const storePath = join(root, 'state', 'completed-turn-receipts.json');
142
+ try {
143
+ insertTurn(db, { session: 'target', origin: 'origin', turn: 7, createdAt: 1234 });
144
+ db.prepare(
145
+ `UPDATE bodies SET text = CASE role WHEN 'user' THEN ' request\r\n' ELSE 'answer' END
146
+ WHERE session_id = 'target' AND origin_session_id = 'origin' AND turn_number = 7`,
147
+ ).run();
148
+ const first = publishCapturedClaudeCompletionReceipt(db, {
149
+ target: 'target', origin: 'origin', turnNumber: 7, projectPath: '/repo',
150
+ receiptOptions: { storePath },
151
+ });
152
+ const second = publishCapturedClaudeCompletionReceipt(db, {
153
+ target: 'target', origin: 'origin', turnNumber: 7, projectPath: '/repo',
154
+ receiptOptions: { storePath },
155
+ });
156
+ assert.equal(first.sequence, 1);
157
+ assert.deepEqual(second, first, 'Stop retry must return the original receipt');
158
+ assert.equal(first.completed_at, 1234);
159
+ if (process.platform !== 'win32') {
160
+ // POSIX permission 契約。Windows の stat mode は 0o666 系で chmod 契約を
161
+ // 表現できない (receipt store の Windows private 化は runtime-error-store の
162
+ // ACL 方式に倣う Observer 側の未着手課題)。
163
+ assert.equal(statSync(join(root, 'state')).mode & 0o777, 0o700);
164
+ assert.equal(statSync(storePath).mode & 0o777, 0o600);
165
+ }
166
+ const bytes = readFileSync(storePath, 'utf8');
167
+ assert.doesNotMatch(bytes, /request|answer|\/repo/);
168
+ assert.match(bytes, /"host":"claude"/);
169
+ } finally {
170
+ db.close();
171
+ rmSync(root, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ test('publishCapturedClaudeCompletionReceipt: incomplete DB pairはreceiptを作らず失敗する', () => {
176
+ const db = makeDb();
177
+ const root = mkdtempSync(join(tmpdir(), 'throughline-turn-receipt-'));
178
+ const storePath = join(root, 'state', 'completed-turn-receipts.json');
179
+ try {
180
+ db.prepare(
181
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
182
+ VALUES ('target', 'origin', 8, 'user', 'only user', 1, 1)`,
183
+ ).run();
184
+ assert.throws(
185
+ () => publishCapturedClaudeCompletionReceipt(db, {
186
+ target: 'target', origin: 'origin', turnNumber: 8, projectPath: '/repo',
187
+ receiptOptions: { storePath },
188
+ }),
189
+ /completed pair was not captured/,
190
+ );
191
+ assert.throws(() => statSync(storePath), { code: 'ENOENT' });
192
+ } finally {
193
+ db.close();
194
+ rmSync(root, { recursive: true, force: true });
195
+ }
196
+ });
197
+
57
198
  test('countDistinctBodyTurns: 2 ロール行 = 1 ターンとして数える', () => {
58
199
  const db = makeDb();
59
200
  insertTurn(db, { session: 'S', origin: 'S', turn: 1, createdAt: 100 });
@@ -27,3 +27,32 @@ if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO
27
27
  });
28
28
  if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture setup failed');
29
29
  }
30
+
31
+ export function verifyWindowsPrivateAcl(path, directory = false) {
32
+ if (process.platform !== 'win32') return;
33
+ const script = String.raw`
34
+ $ErrorActionPreference='Stop'
35
+ $target=$env:THROUGHLINE_TEST_ACL_PATH; $isDir=$env:THROUGHLINE_TEST_ACL_DIRECTORY -eq '1'
36
+ $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
37
+ $acl=if($isDir){[System.IO.Directory]::GetAccessControl($target)}else{[System.IO.File]::GetAccessControl($target)}
38
+ $owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
39
+ if($owner -ne $sid){exit 41}
40
+ $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))
41
+ if($rules.Count -ne 1){exit 42}
42
+ $rule=$rules[0]
43
+ if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow' -or $rule.IsInherited -or ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
44
+ `;
45
+ const result = spawnSync('powershell.exe', [
46
+ '-NoProfile', '-NonInteractive', '-Command', script,
47
+ ], {
48
+ encoding: 'utf8',
49
+ timeout: 3_000,
50
+ windowsHide: true,
51
+ env: {
52
+ ...process.env,
53
+ THROUGHLINE_TEST_ACL_PATH: path,
54
+ THROUGHLINE_TEST_ACL_DIRECTORY: directory ? '1' : '0',
55
+ },
56
+ });
57
+ if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture verification failed');
58
+ }