throughline 0.6.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.
@@ -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
+ }
@@ -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
+ }
@@ -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) === normalizedProject : false;
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) === normalizedProject : false;
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
+ }