throughline 0.5.0 → 0.6.1
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.
- package/.codex-sidecar.yml +5 -0
- package/CHANGELOG.md +60 -2
- package/README.ja.md +37 -21
- package/README.md +79 -29
- package/bin/throughline.mjs +10 -0
- package/docs/00_overview.md +34 -0
- package/docs/{L1_L2_L3_REDESIGN.md → 01_l1_l2_l3_redesign.md} +3 -3
- package/docs/{THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md → 02_clear_auto_handoff_plan.md} +6 -6
- package/docs/{INHERITANCE_ON_CLEAR_ONLY.md → 03_inheritance_on_clear_only.md} +3 -3
- package/docs/{PUBLIC_RELEASE_PLAN.md → 04_public_release_plan.md} +3 -3
- package/docs/{THROUGHLINE_CODEX_FIRST_ROADMAP.md → 05_codex_first_roadmap.md} +9 -9
- package/docs/{THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md → 06_codex_trim_rollback_fix_plan.md} +6 -6
- package/docs/{THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md → 07_codex_trim_implementation_plan.md} +10 -10
- package/docs/{THROUGHLINE_CODEX_DUAL_SUPPORT.md → 08_codex_dual_support.md} +8 -8
- package/docs/{throughline-rollback-context-trim-insight.md → 09_rollback_context_trim_insight.md} +5 -5
- package/docs/{THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md → 10_transcript_injection_plan.md} +6 -6
- package/docs/{THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md → 11_codex_monitor_implementation_plan.md} +1 -1
- package/docs/12_desktop_clear_handoff_plan.md +215 -0
- package/docs/adr/0001-claude-primary-codex-adapter.md +22 -0
- package/docs/archive/README.md +3 -3
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +3 -3
- package/package.json +2 -1
- package/rag/01-hooks/raw/session-end-reasons.md +21 -0
- package/{docs/RAG → rag}/INDEX.md +20 -16
- package/src/auditor-context.mjs +330 -0
- package/src/auditor-context.test.mjs +296 -0
- package/src/baton.mjs +2 -2
- package/src/cli/auditor-context.mjs +141 -0
- package/src/cli/auditor-context.test.mjs +148 -0
- package/src/cli/codex-hook.mjs +20 -0
- package/src/codex-thread-index.mjs +11 -2
- package/src/db.mjs +2 -2
- package/src/hook-entrypoints.test.mjs +102 -0
- package/src/package-files.test.mjs +1 -0
- package/src/phase0-spotter-contract.test.mjs +280 -0
- package/src/prompt-submit.mjs +2 -2
- package/src/resume-context.mjs +1 -1
- package/src/session-merger.mjs +1 -1
- package/src/session-start.mjs +62 -3
- package/src/spike-transcript-writer.mjs +1 -1
- package/src/state-file.mjs +1 -1
- package/src/token-monitor.mjs +1 -1
- package/src/transcript-reader.mjs +71 -0
- package/src/turn-backfill.mjs +131 -0
- package/src/turn-backfill.test.mjs +213 -0
- package/src/turn-processor.mjs +28 -40
- /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
- /package/{docs/RAG/_raw/01-hooks → rag/01-hooks/raw}/hooks-reference-extract.md +0 -0
- /package/{docs/RAG/_raw/02-messages-api → rag/02-messages-api/raw}/messages-api-extract.md +0 -0
- /package/{docs/RAG/_raw/03-settings → rag/03-settings/raw}/sessions-extract.md +0 -0
- /package/{docs/RAG/_raw/04-skills → rag/04-skills/raw}/initialUserMessage-investigation.md +0 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, lstatSync, mkdtempSync, readFileSync, 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
|
+
|
|
8
|
+
import {
|
|
9
|
+
AUDITOR_CONTEXT_SCHEMA,
|
|
10
|
+
deriveAuditorFreshnessExpectation,
|
|
11
|
+
hashAuditorBody,
|
|
12
|
+
readAuditorContext,
|
|
13
|
+
} from './auditor-context.mjs';
|
|
14
|
+
|
|
15
|
+
function withDb(fn, { version = 8 } = {}) {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-context-'));
|
|
17
|
+
const path = join(dir, 'throughline.db');
|
|
18
|
+
const db = new DatabaseSync(path);
|
|
19
|
+
db.exec(`
|
|
20
|
+
PRAGMA journal_mode = WAL;
|
|
21
|
+
PRAGMA user_version = ${version};
|
|
22
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
23
|
+
CREATE TABLE bodies (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
session_id TEXT NOT NULL,
|
|
26
|
+
origin_session_id TEXT NOT NULL,
|
|
27
|
+
turn_number INTEGER NOT NULL,
|
|
28
|
+
role TEXT NOT NULL,
|
|
29
|
+
text TEXT NOT NULL,
|
|
30
|
+
created_at INTEGER NOT NULL
|
|
31
|
+
);
|
|
32
|
+
`);
|
|
33
|
+
try {
|
|
34
|
+
return fn({ db, path, dir });
|
|
35
|
+
} finally {
|
|
36
|
+
db.close();
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function seedSession(db, { sessionId = 'session-1', projectPath = '/repo', pairs = [] } = {}) {
|
|
42
|
+
db.prepare('INSERT INTO sessions (session_id, project_path) VALUES (?, ?)').run(sessionId, projectPath);
|
|
43
|
+
let createdAt = 1;
|
|
44
|
+
for (const pair of pairs) {
|
|
45
|
+
for (const [role, text] of [['user', pair.user], ['assistant', pair.assistant]]) {
|
|
46
|
+
db.prepare(
|
|
47
|
+
'INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
48
|
+
).run(sessionId, pair.originSessionId, pair.turnNumber, role, text, createdAt++);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function expected(pair) {
|
|
54
|
+
return {
|
|
55
|
+
expectedOriginSessionId: pair.originSessionId,
|
|
56
|
+
expectedTurnNumber: pair.turnNumber,
|
|
57
|
+
expectedUserSha256: hashAuditorBody(pair.user),
|
|
58
|
+
expectedAssistantSha256: hashAuditorBody(pair.assistant),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function read(path, pair, extra = {}) {
|
|
63
|
+
return readAuditorContext({
|
|
64
|
+
dbPath: path,
|
|
65
|
+
sessionId: 'session-1',
|
|
66
|
+
projectRoot: '/repo',
|
|
67
|
+
...expected(pair),
|
|
68
|
+
...extra,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('readAuditorContext: returns a fresh completed pair with canonical origin identity', () => {
|
|
73
|
+
withDb(({ db, path }) => {
|
|
74
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 4, user: ' ask\r\n', assistant: ' answer\r\n' };
|
|
75
|
+
seedSession(db, { pairs: [pair] });
|
|
76
|
+
|
|
77
|
+
const result = read(path, pair);
|
|
78
|
+
assert.equal(result.schema, AUDITOR_CONTEXT_SCHEMA);
|
|
79
|
+
assert.equal(result.status, 'fresh');
|
|
80
|
+
assert.equal(result.reason, 'latest_pair_matched');
|
|
81
|
+
assert.deepEqual(result.turns, [
|
|
82
|
+
{ originSessionId: 'origin-a', turnNumber: 4, user: 'ask', assistant: 'answer', createdAt: 2 },
|
|
83
|
+
]);
|
|
84
|
+
assert.deepEqual(result.freshness, {
|
|
85
|
+
originSessionId: 'origin-a', turnNumber: 4, identityMatched: true, userMatched: true, assistantMatched: true,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('readAuditorContext: pair identity or hash mismatch is stale', () => {
|
|
91
|
+
withDb(({ db, path }) => {
|
|
92
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 4, user: 'ask', assistant: 'answer' };
|
|
93
|
+
seedSession(db, { pairs: [pair] });
|
|
94
|
+
|
|
95
|
+
for (const extra of [
|
|
96
|
+
{ expectedOriginSessionId: 'origin-other' },
|
|
97
|
+
{ expectedTurnNumber: 5 },
|
|
98
|
+
{ expectedUserSha256: hashAuditorBody('other') },
|
|
99
|
+
{ expectedAssistantSha256: hashAuditorBody('other') },
|
|
100
|
+
]) {
|
|
101
|
+
const result = read(path, pair, extra);
|
|
102
|
+
assert.equal(result.status, 'stale');
|
|
103
|
+
assert.equal(result.reason, 'latest_pair_mismatch');
|
|
104
|
+
assert.deepEqual(result.turns, []);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('readAuditorContext: returns empty for no complete pair and excludes developer/L3 roles', () => {
|
|
110
|
+
withDb(({ db, path }) => {
|
|
111
|
+
seedSession(db);
|
|
112
|
+
db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
113
|
+
.run('session-1', 'origin-a', 1, 'developer', 'secret developer context', 1);
|
|
114
|
+
db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
115
|
+
.run('session-1', 'origin-a', 1, 'user', 'unpaired request', 2);
|
|
116
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'unpaired request', assistant: 'missing' };
|
|
117
|
+
|
|
118
|
+
const result = read(path, pair);
|
|
119
|
+
assert.equal(result.status, 'empty');
|
|
120
|
+
assert.equal(result.reason, 'completed_pair_not_found');
|
|
121
|
+
assert.deepEqual(result.turns, []);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('readAuditorContext: reports project mismatch without exposing rows', () => {
|
|
126
|
+
withDb(({ db, path }) => {
|
|
127
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'private', assistant: 'private reply' };
|
|
128
|
+
seedSession(db, { projectPath: '/other-project', pairs: [pair] });
|
|
129
|
+
const result = read(path, pair);
|
|
130
|
+
assert.equal(result.status, 'session_mismatch');
|
|
131
|
+
assert.equal(result.reason, 'project_mismatch');
|
|
132
|
+
assert.deepEqual(result.turns, []);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('readAuditorContext: applies recent-turn, body, and total bounds from the newest pairs', () => {
|
|
137
|
+
withDb(({ db, path }) => {
|
|
138
|
+
const old = { originSessionId: 'origin-a', turnNumber: 1, user: 'old user', assistant: 'old assistant' };
|
|
139
|
+
const latest = { originSessionId: 'origin-a', turnNumber: 2, user: 'abcdef', assistant: 'uvwxyz' };
|
|
140
|
+
seedSession(db, { pairs: [old, latest] });
|
|
141
|
+
const result = read(path, latest, { recentTurns: 1, maxBodyChars: 4, maxTotalChars: 5 });
|
|
142
|
+
assert.equal(result.status, 'fresh');
|
|
143
|
+
assert.deepEqual(result.turns, [
|
|
144
|
+
{ originSessionId: 'origin-a', turnNumber: 2, user: 'ef', assistant: 'xyz', createdAt: 4 },
|
|
145
|
+
]);
|
|
146
|
+
assert.deepEqual(result.stats, { requestedTurns: 1, returnedTurns: 1, chars: 5, truncated: true });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('readAuditorContext: reports schema mismatch and missing DB as exit-safe JSON states', () => {
|
|
151
|
+
withDb(({ db, path }) => {
|
|
152
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
153
|
+
seedSession(db, { pairs: [pair] });
|
|
154
|
+
const mismatched = read(path, pair);
|
|
155
|
+
assert.equal(mismatched.status, 'schema_mismatch');
|
|
156
|
+
assert.equal(mismatched.reason, 'unsupported_db_schema');
|
|
157
|
+
}, { version: 7 });
|
|
158
|
+
|
|
159
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
160
|
+
const missing = read('/definitely-missing-throughline-auditor-context.db', pair);
|
|
161
|
+
assert.equal(missing.status, 'unavailable');
|
|
162
|
+
assert.equal(missing.reason, 'db_not_found');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('readAuditorContext: opens a live WAL database read-only without changing DB, -wal, or -shm', () => {
|
|
166
|
+
withDb(({ db, path }) => {
|
|
167
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
168
|
+
seedSession(db, { pairs: [pair] });
|
|
169
|
+
db.exec('BEGIN IMMEDIATE');
|
|
170
|
+
const before = snapshotSqliteFiles(path);
|
|
171
|
+
const result = read(path, pair);
|
|
172
|
+
assert.equal(result.status, 'fresh');
|
|
173
|
+
assert.deepEqual(snapshotSqliteFiles(path), before);
|
|
174
|
+
db.exec('ROLLBACK');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('readAuditorContext: classifies an exclusive database lock without exposing SQLite diagnostics', () => {
|
|
179
|
+
withDb(({ db, path }) => {
|
|
180
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'u', assistant: 'a' };
|
|
181
|
+
seedSession(db, { pairs: [pair] });
|
|
182
|
+
db.exec('PRAGMA journal_mode = DELETE; BEGIN EXCLUSIVE');
|
|
183
|
+
try {
|
|
184
|
+
assert.throws(
|
|
185
|
+
() => read(path, pair),
|
|
186
|
+
(error) => {
|
|
187
|
+
assert.equal(error.code, 'E_AUDITOR_CONTEXT_QUERY');
|
|
188
|
+
assert.equal(error.message, 'auditor context query failed');
|
|
189
|
+
assert.equal(error.message.includes('locked'), false);
|
|
190
|
+
return true;
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
db.exec('ROLLBACK');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('deriveAuditorFreshnessExpectation: Claude logical groups use the latest representative fragment and session origin', () => {
|
|
200
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-claude-transcript-'));
|
|
201
|
+
const transcript = join(dir, 'session.jsonl');
|
|
202
|
+
try {
|
|
203
|
+
writeFileSync(transcript, [
|
|
204
|
+
claudeRow('user', 'first request'),
|
|
205
|
+
claudeRow('assistant', 'first answer'),
|
|
206
|
+
claudeRow('user', 'latest request'),
|
|
207
|
+
claudeRow('assistant', 'partial answer'),
|
|
208
|
+
claudeRow('assistant', 'latest representative answer'),
|
|
209
|
+
].map(JSON.stringify).join('\n'));
|
|
210
|
+
|
|
211
|
+
assert.deepEqual(
|
|
212
|
+
deriveAuditorFreshnessExpectation({ host: 'claude', transcriptPath: transcript, sessionId: 'claude-session' }),
|
|
213
|
+
{
|
|
214
|
+
expectedOriginSessionId: 'claude-session',
|
|
215
|
+
expectedTurnNumber: 4,
|
|
216
|
+
expectedUserSha256: hashAuditorBody('latest request'),
|
|
217
|
+
expectedAssistantSha256: hashAuditorBody('latest representative answer'),
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
} finally {
|
|
221
|
+
rmSync(dir, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('deriveAuditorFreshnessExpectation: Codex excludes current in-flight turn before deriving latest completed identity', () => {
|
|
226
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-codex-rollout-'));
|
|
227
|
+
const rollout = join(dir, 'rollout.jsonl');
|
|
228
|
+
try {
|
|
229
|
+
writeFileSync(rollout, [
|
|
230
|
+
codexEvent('user_message', { message: 'completed request' }),
|
|
231
|
+
codexEvent('task_started'),
|
|
232
|
+
codexEvent('agent_message', { message: 'completed answer' }),
|
|
233
|
+
codexEvent('task_complete'),
|
|
234
|
+
codexEvent('user_message', { message: 'in-flight request' }),
|
|
235
|
+
codexEvent('task_started'),
|
|
236
|
+
codexEvent('agent_message', { message: 'in-flight answer' }),
|
|
237
|
+
].map(JSON.stringify).join('\n'));
|
|
238
|
+
|
|
239
|
+
assert.deepEqual(
|
|
240
|
+
deriveAuditorFreshnessExpectation({ host: 'codex', transcriptPath: rollout, sessionId: 'codex:thread-1' }),
|
|
241
|
+
{
|
|
242
|
+
expectedOriginSessionId: 'codex:thread-1',
|
|
243
|
+
expectedTurnNumber: null,
|
|
244
|
+
expectedUserSha256: hashAuditorBody('completed request'),
|
|
245
|
+
expectedAssistantSha256: hashAuditorBody('completed answer'),
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
} finally {
|
|
249
|
+
rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('readAuditorContext: Codex freshness uses exact origin and both pair hashes when turn ordinals are unstable', () => {
|
|
254
|
+
withDb(({ db, path }) => {
|
|
255
|
+
seedSession(db, {
|
|
256
|
+
sessionId: 'codex:thread-1',
|
|
257
|
+
projectPath: '/repo',
|
|
258
|
+
pairs: [{
|
|
259
|
+
originSessionId: 'codex:thread-1', turnNumber: 32,
|
|
260
|
+
user: 'completed request', assistant: 'completed answer',
|
|
261
|
+
}, {
|
|
262
|
+
originSessionId: 'codex:thread-1', turnNumber: 33,
|
|
263
|
+
user: 'transient request', assistant: 'transient answer',
|
|
264
|
+
}],
|
|
265
|
+
});
|
|
266
|
+
const result = readAuditorContext({
|
|
267
|
+
dbPath: path,
|
|
268
|
+
sessionId: 'codex:thread-1',
|
|
269
|
+
projectRoot: '/repo',
|
|
270
|
+
expectedOriginSessionId: 'codex:thread-1',
|
|
271
|
+
expectedTurnNumber: null,
|
|
272
|
+
expectedUserSha256: hashAuditorBody('completed request'),
|
|
273
|
+
expectedAssistantSha256: hashAuditorBody('completed answer'),
|
|
274
|
+
});
|
|
275
|
+
assert.equal(result.status, 'fresh');
|
|
276
|
+
assert.equal(result.freshness.turnNumber, 32);
|
|
277
|
+
assert.equal(result.turns.at(-1).turnNumber, 32);
|
|
278
|
+
assert.equal(result.turns.some((turn) => turn.turnNumber === 33), false);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
function snapshotSqliteFiles(path) {
|
|
283
|
+
return [path, `${path}-wal`, `${path}-shm`].map((file) => {
|
|
284
|
+
if (!existsSync(file)) return { file, exists: false };
|
|
285
|
+
const stat = lstatSync(file);
|
|
286
|
+
return { file, exists: true, size: stat.size, mtimeMs: stat.mtimeMs, bytes: readFileSync(file).toString('hex') };
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function claudeRow(role, text) {
|
|
291
|
+
return { type: role, message: { role, content: [{ type: 'text', text }] } };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function codexEvent(type, payload = {}) {
|
|
295
|
+
return { timestamp: '2026-07-13T00:00:00.000Z', type: 'event_msg', payload: { type, ...payload } };
|
|
296
|
+
}
|
package/src/baton.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* baton.mjs — 引き継ぎバトン管理
|
|
3
3
|
*
|
|
4
|
-
* バトン方式の設計 (docs/
|
|
4
|
+
* バトン方式の設計 (docs/02_clear_auto_handoff_plan.md):
|
|
5
5
|
* - 新仕様では `/clear` 自動引継ぎがデフォルト ON。バトンは「/clear 自動引継ぎを
|
|
6
6
|
* 使わずに明示的に引き継ぎたい」ユーザーのための逃げ道。
|
|
7
7
|
* - ユーザーが旧セッションで `/tl` スラッシュコマンドを打つ → UserPromptSubmit hook が
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* "startup" に潰される問題 (#49937) に対する明示意思マーカーとして導入。
|
|
15
15
|
* 2026-05-08 時点で Claude Code 2.1.128 で source='clear' は reliable に
|
|
16
16
|
* なったため auto path 中心の設計に変わったが、明示意思の signal として
|
|
17
|
-
* baton 仕組み自体は残す。詳細は docs/
|
|
17
|
+
* baton 仕組み自体は残す。詳細は docs/02_clear_auto_handoff_plan.md。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUDITOR_CONTEXT_SCHEMA,
|
|
3
|
+
deriveAuditorFreshnessExpectation,
|
|
4
|
+
readAuditorContext,
|
|
5
|
+
} from '../auditor-context.mjs';
|
|
6
|
+
|
|
7
|
+
const ERROR_SCHEMA = AUDITOR_CONTEXT_SCHEMA;
|
|
8
|
+
const ARGS_ERROR = {
|
|
9
|
+
schema: ERROR_SCHEMA,
|
|
10
|
+
status: 'error',
|
|
11
|
+
code: 'E_AUDITOR_CONTEXT_ARGS',
|
|
12
|
+
message: 'invalid auditor-context arguments',
|
|
13
|
+
};
|
|
14
|
+
const INTERNAL_ERROR = {
|
|
15
|
+
schema: ERROR_SCHEMA,
|
|
16
|
+
status: 'error',
|
|
17
|
+
code: 'E_AUDITOR_CONTEXT_INTERNAL',
|
|
18
|
+
message: 'auditor context could not be read',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function parseArgs(argv = []) {
|
|
22
|
+
const out = {
|
|
23
|
+
sessionId: null,
|
|
24
|
+
projectRoot: null,
|
|
25
|
+
expectedOriginSessionId: null,
|
|
26
|
+
expectedTurnNumber: null,
|
|
27
|
+
expectedUserSha256: null,
|
|
28
|
+
expectedAssistantSha256: null,
|
|
29
|
+
recentTurns: undefined,
|
|
30
|
+
maxBodyChars: undefined,
|
|
31
|
+
maxTotalChars: undefined,
|
|
32
|
+
dbPath: undefined,
|
|
33
|
+
host: null,
|
|
34
|
+
transcriptPath: null,
|
|
35
|
+
json: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (let index = 0; index < argv.length; index++) {
|
|
39
|
+
const arg = argv[index];
|
|
40
|
+
if (arg === '--json') {
|
|
41
|
+
out.json = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const value = argv[++index];
|
|
45
|
+
if (!value || value.startsWith('-')) throw new TypeError('missing option value');
|
|
46
|
+
if (arg === '--session') out.sessionId = value;
|
|
47
|
+
else if (arg === '--project') out.projectRoot = value;
|
|
48
|
+
else if (arg === '--expected-origin-session') out.expectedOriginSessionId = value;
|
|
49
|
+
else if (arg === '--expected-turn-number') out.expectedTurnNumber = parseNonNegativeInteger(value);
|
|
50
|
+
else if (arg === '--expected-user-sha256') out.expectedUserSha256 = parseSha256(value);
|
|
51
|
+
else if (arg === '--expected-assistant-sha256') out.expectedAssistantSha256 = parseSha256(value);
|
|
52
|
+
else if (arg === '--recent-turns') out.recentTurns = parsePositiveInteger(value);
|
|
53
|
+
else if (arg === '--max-body-chars') out.maxBodyChars = parsePositiveInteger(value);
|
|
54
|
+
else if (arg === '--max-total-chars') out.maxTotalChars = parsePositiveInteger(value);
|
|
55
|
+
else if (arg === '--db') out.dbPath = value;
|
|
56
|
+
else if (arg === '--host' && (value === 'claude' || value === 'codex')) out.host = value;
|
|
57
|
+
else if (arg === '--transcript') out.transcriptPath = value;
|
|
58
|
+
else throw new TypeError('unknown option');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
!out.json ||
|
|
63
|
+
!out.sessionId ||
|
|
64
|
+
!out.projectRoot ||
|
|
65
|
+
!hasFreshnessSource(out)
|
|
66
|
+
) {
|
|
67
|
+
throw new TypeError('missing required option');
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function run(argv = [], {
|
|
73
|
+
read = readAuditorContext,
|
|
74
|
+
deriveExpectation = deriveAuditorFreshnessExpectation,
|
|
75
|
+
stdout = process.stdout,
|
|
76
|
+
stderr = process.stderr,
|
|
77
|
+
} = {}) {
|
|
78
|
+
let args;
|
|
79
|
+
try {
|
|
80
|
+
args = parseArgs(argv);
|
|
81
|
+
} catch {
|
|
82
|
+
writeJson(stderr, ARGS_ERROR);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const derived = args.host
|
|
88
|
+
? deriveExpectation({ host: args.host, transcriptPath: args.transcriptPath, sessionId: args.sessionId })
|
|
89
|
+
: null;
|
|
90
|
+
const result = read({
|
|
91
|
+
dbPath: args.dbPath,
|
|
92
|
+
sessionId: args.sessionId,
|
|
93
|
+
projectRoot: args.projectRoot,
|
|
94
|
+
expectedOriginSessionId: derived?.expectedOriginSessionId ?? args.expectedOriginSessionId,
|
|
95
|
+
expectedTurnNumber: derived?.expectedTurnNumber ?? args.expectedTurnNumber,
|
|
96
|
+
expectedUserSha256: derived?.expectedUserSha256 ?? args.expectedUserSha256,
|
|
97
|
+
expectedAssistantSha256: derived?.expectedAssistantSha256 ?? args.expectedAssistantSha256,
|
|
98
|
+
recentTurns: args.recentTurns,
|
|
99
|
+
maxBodyChars: args.maxBodyChars,
|
|
100
|
+
maxTotalChars: args.maxTotalChars,
|
|
101
|
+
});
|
|
102
|
+
writeJson(stdout, result);
|
|
103
|
+
return 0;
|
|
104
|
+
} catch {
|
|
105
|
+
writeJson(stderr, INTERNAL_ERROR);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasFreshnessSource(args) {
|
|
111
|
+
const hasTranscript = Boolean(args.host && args.transcriptPath);
|
|
112
|
+
const hasExplicitPair = Boolean(
|
|
113
|
+
args.expectedOriginSessionId &&
|
|
114
|
+
args.expectedTurnNumber !== null &&
|
|
115
|
+
args.expectedUserSha256 &&
|
|
116
|
+
args.expectedAssistantSha256
|
|
117
|
+
);
|
|
118
|
+
return hasTranscript !== hasExplicitPair;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseNonNegativeInteger(value) {
|
|
122
|
+
if (!/^\d+$/.test(value)) throw new TypeError('invalid integer');
|
|
123
|
+
const parsed = Number(value);
|
|
124
|
+
if (!Number.isSafeInteger(parsed)) throw new TypeError('invalid integer');
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parsePositiveInteger(value) {
|
|
129
|
+
const parsed = parseNonNegativeInteger(value);
|
|
130
|
+
if (parsed < 1) throw new TypeError('invalid integer');
|
|
131
|
+
return parsed;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseSha256(value) {
|
|
135
|
+
if (!/^[a-fA-F0-9]{64}$/.test(value)) throw new TypeError('invalid hash');
|
|
136
|
+
return value.toLowerCase();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function writeJson(stream, value) {
|
|
140
|
+
stream.write(`${JSON.stringify(value)}\n`);
|
|
141
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
|
|
10
|
+
import { AUDITOR_CONTEXT_SCHEMA, hashAuditorBody } from '../auditor-context.mjs';
|
|
11
|
+
import { run } from './auditor-context.mjs';
|
|
12
|
+
|
|
13
|
+
const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
|
|
14
|
+
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
15
|
+
const USER = 'auditor user body';
|
|
16
|
+
const ASSISTANT = 'auditor assistant body';
|
|
17
|
+
|
|
18
|
+
function makeDb({ originSessionId = 'origin-1' } = {}) {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-cli-'));
|
|
20
|
+
const path = join(dir, 'throughline.db');
|
|
21
|
+
const db = new DatabaseSync(path);
|
|
22
|
+
db.exec(`
|
|
23
|
+
PRAGMA user_version = 8;
|
|
24
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
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
|
+
INSERT INTO sessions VALUES ('session-1', '/repo');
|
|
27
|
+
INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES ('session-1', '${originSessionId}', 3, 'user', '${USER}', 1);
|
|
28
|
+
INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES ('session-1', '${originSessionId}', 3, 'assistant', '${ASSISTANT}', 2);
|
|
29
|
+
`);
|
|
30
|
+
db.close();
|
|
31
|
+
return { dir, path };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function args(path) {
|
|
35
|
+
return [
|
|
36
|
+
'auditor-context', '--session', 'session-1', '--project', '/repo', '--expected-origin-session', 'origin-1',
|
|
37
|
+
'--expected-turn-number', '3', '--expected-user-sha256', hashAuditorBody(USER),
|
|
38
|
+
'--expected-assistant-sha256', hashAuditorBody(ASSISTANT), '--db', path, '--json',
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('auditor-context CLI prints only fresh JSON to stdout', () => {
|
|
43
|
+
const { dir, path } = makeDb();
|
|
44
|
+
try {
|
|
45
|
+
const result = spawnSync(process.execPath, [BIN_PATH, ...args(path)], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
46
|
+
assert.equal(result.status, 0, result.stderr);
|
|
47
|
+
assert.equal(result.stderr, '');
|
|
48
|
+
const output = JSON.parse(result.stdout);
|
|
49
|
+
assert.equal(output.schema, AUDITOR_CONTEXT_SCHEMA);
|
|
50
|
+
assert.equal(output.status, 'fresh');
|
|
51
|
+
assert.equal(output.turns[0].user, USER);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('auditor-context CLI transcript freshness mode derives a fresh Claude pair', () => {
|
|
58
|
+
const { dir, path } = makeDb({ originSessionId: 'session-1' });
|
|
59
|
+
const transcript = join(dir, 'claude.jsonl');
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(transcript, [
|
|
62
|
+
claudeRow('user', 'earlier request'),
|
|
63
|
+
claudeRow('assistant', 'earlier answer'),
|
|
64
|
+
claudeRow('user', USER),
|
|
65
|
+
claudeRow('assistant', ASSISTANT),
|
|
66
|
+
].map(JSON.stringify).join('\n'));
|
|
67
|
+
const result = spawnSync(process.execPath, [
|
|
68
|
+
BIN_PATH, 'auditor-context', '--session', 'session-1', '--project', '/repo', '--host', 'claude',
|
|
69
|
+
'--transcript', transcript, '--db', path, '--json',
|
|
70
|
+
], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
71
|
+
assert.equal(result.status, 0, result.stderr);
|
|
72
|
+
assert.equal(result.stderr, '');
|
|
73
|
+
assert.equal(JSON.parse(result.stdout).status, 'fresh');
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('auditor-context CLI rejects mixed and partial freshness sources with the fixed args error', () => {
|
|
80
|
+
const transcript = '/tmp/auditor-context-transcript.jsonl';
|
|
81
|
+
const base = ['--session', 's', '--project', '/p', '--json'];
|
|
82
|
+
const explicit = [
|
|
83
|
+
'--expected-origin-session', 'origin', '--expected-turn-number', '1',
|
|
84
|
+
'--expected-user-sha256', 'a'.repeat(64), '--expected-assistant-sha256', 'b'.repeat(64),
|
|
85
|
+
];
|
|
86
|
+
const transcriptSource = ['--host', 'claude', '--transcript', transcript];
|
|
87
|
+
for (const argv of [
|
|
88
|
+
[...base, ...explicit, ...transcriptSource],
|
|
89
|
+
[...base, '--host', 'claude'],
|
|
90
|
+
[...base, '--transcript', transcript],
|
|
91
|
+
[...base, '--expected-origin-session', 'origin'],
|
|
92
|
+
]) {
|
|
93
|
+
const stderr = { text: '', write(value) { this.text += value; } };
|
|
94
|
+
const stdout = { text: '', write(value) { this.text += value; } };
|
|
95
|
+
assert.equal(run(argv, { stdout, stderr }), 1);
|
|
96
|
+
assert.equal(stdout.text, '');
|
|
97
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
98
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_ARGS', message: 'invalid auditor-context arguments',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('auditor-context CLI transcript parse failures use fixed JSON without leaking transcript path or body', () => {
|
|
104
|
+
const transcript = '/private/path/with-secret-body.jsonl';
|
|
105
|
+
const stderr = { text: '', write(value) { this.text += value; } };
|
|
106
|
+
const stdout = { text: '', write(value) { this.text += value; } };
|
|
107
|
+
const argv = ['--session', 's', '--project', '/p', '--host', 'codex', '--transcript', transcript, '--json'];
|
|
108
|
+
assert.equal(run(argv, {
|
|
109
|
+
deriveExpectation: () => { throw new Error(`parse failure ${transcript} ${USER}`); },
|
|
110
|
+
stdout,
|
|
111
|
+
stderr,
|
|
112
|
+
}), 1);
|
|
113
|
+
assert.equal(stdout.text, '');
|
|
114
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
115
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_INTERNAL', message: 'auditor context could not be read',
|
|
116
|
+
});
|
|
117
|
+
assert.doesNotMatch(stderr.text, /private|secret|auditor user|jsonl/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('auditor-context CLI returns fixed JSON errors without raw body, hash, DB, or thrown error text', () => {
|
|
121
|
+
const stderr = { text: '', write(value) { this.text += value; } };
|
|
122
|
+
const stdout = { text: '', write(value) { this.text += value; } };
|
|
123
|
+
assert.equal(run(['--session', 'only'], { stdout, stderr }), 1);
|
|
124
|
+
assert.equal(stdout.text, '');
|
|
125
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
126
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_ARGS', message: 'invalid auditor-context arguments',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
stderr.text = '';
|
|
130
|
+
assert.equal(run(['--session', 's', '--project', '/p', '--expected-origin-session', 'o', '--expected-turn-number', '1', '--expected-user-sha256', 'a'.repeat(64), '--expected-assistant-sha256', 'b'.repeat(64), '--json'], {
|
|
131
|
+
read: () => { throw new Error(`private ${USER} ${hashAuditorBody(USER)} /secret.db`); }, stdout, stderr,
|
|
132
|
+
}), 1);
|
|
133
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
134
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_INTERNAL', message: 'auditor context could not be read',
|
|
135
|
+
});
|
|
136
|
+
assert.doesNotMatch(stderr.text, /private|secret|auditor user|[a-f0-9]{64}/i);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('auditor-context bin dispatch and help advertise the JSON-only command', () => {
|
|
140
|
+
const help = spawnSync(process.execPath, [BIN_PATH, '--help'], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
141
|
+
assert.equal(help.status, 0, help.stderr);
|
|
142
|
+
assert.match(help.stdout, /throughline auditor-context --session <id> --project <root>/);
|
|
143
|
+
assert.match(help.stdout, /always requires --json/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function claudeRow(role, text) {
|
|
147
|
+
return { type: role, message: { role, content: [{ type: 'text', text }] } };
|
|
148
|
+
}
|
package/src/cli/codex-hook.mjs
CHANGED
|
@@ -88,6 +88,21 @@ async function captureCodexHookSession({
|
|
|
88
88
|
buildMonitorUsage = null,
|
|
89
89
|
summarize = true,
|
|
90
90
|
} = {}) {
|
|
91
|
+
if (isSpotterChildEnvironment(env)) {
|
|
92
|
+
return {
|
|
93
|
+
status: 'skipped',
|
|
94
|
+
reason: 'spotter_child_backend',
|
|
95
|
+
db,
|
|
96
|
+
identity: null,
|
|
97
|
+
projectPath: null,
|
|
98
|
+
codexHome: null,
|
|
99
|
+
captured: null,
|
|
100
|
+
summarized: null,
|
|
101
|
+
monitorState: null,
|
|
102
|
+
usage: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
const [
|
|
92
107
|
{ getDb },
|
|
93
108
|
{ captureCodexRolloutToDb },
|
|
@@ -202,6 +217,11 @@ async function captureCodexHookSession({
|
|
|
202
217
|
};
|
|
203
218
|
}
|
|
204
219
|
|
|
220
|
+
function isSpotterChildEnvironment(env = {}) {
|
|
221
|
+
return ['SPOTTER_PARENT_PID', 'SPOTTER_BACKEND', 'SPOTTER_CHILD_BACKEND']
|
|
222
|
+
.some((name) => typeof env?.[name] === 'string' && env[name].length > 0);
|
|
223
|
+
}
|
|
224
|
+
|
|
205
225
|
export async function runCodexStopHook({
|
|
206
226
|
args = {},
|
|
207
227
|
payload = {},
|
|
@@ -28,7 +28,7 @@ export function listCodexThreadCandidates({
|
|
|
28
28
|
const meta = readSessionMeta(rollout.path);
|
|
29
29
|
const indexed = index.get(rollout.threadId) ?? {};
|
|
30
30
|
const cwd = meta?.cwd ?? null;
|
|
31
|
-
const matchesProject = cwd ? normalizePath(cwd)
|
|
31
|
+
const matchesProject = cwd ? isSameProjectOrDescendant(normalizePath(cwd), normalizedProject) : false;
|
|
32
32
|
return {
|
|
33
33
|
id: rollout.threadId,
|
|
34
34
|
threadName: indexed.thread_name ?? null,
|
|
@@ -69,7 +69,7 @@ export function findCodexThreadCandidate({
|
|
|
69
69
|
const meta = readSessionMeta(rollout.path);
|
|
70
70
|
const indexed = index.get(rollout.threadId) ?? {};
|
|
71
71
|
const cwd = meta?.cwd ?? null;
|
|
72
|
-
const matchesProject = cwd ? normalizePath(cwd)
|
|
72
|
+
const matchesProject = cwd ? isSameProjectOrDescendant(normalizePath(cwd), normalizedProject) : false;
|
|
73
73
|
return {
|
|
74
74
|
id: rollout.threadId,
|
|
75
75
|
threadName: indexed.thread_name ?? null,
|
|
@@ -169,6 +169,10 @@ function compareCandidates(a, b) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function normalizePath(value) {
|
|
172
|
+
const raw = String(value);
|
|
173
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
174
|
+
return raw.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
175
|
+
}
|
|
172
176
|
let resolved = resolve(value);
|
|
173
177
|
try {
|
|
174
178
|
if (existsSync(resolved)) resolved = realpathSync.native(resolved);
|
|
@@ -177,3 +181,8 @@ function normalizePath(value) {
|
|
|
177
181
|
}
|
|
178
182
|
return resolved.split(sep).join('/').replace(/\/+$/, '').toLowerCase();
|
|
179
183
|
}
|
|
184
|
+
|
|
185
|
+
function isSameProjectOrDescendant(candidate, root) {
|
|
186
|
+
if (!candidate || !root) return false;
|
|
187
|
+
return candidate === root || candidate.startsWith(`${root}/`);
|
|
188
|
+
}
|