throughline 0.6.0 → 0.6.2

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 (47) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +46 -5
  3. package/bin/throughline.mjs +41 -0
  4. package/docs/00_overview.md +2 -0
  5. package/docs/04_public_release_plan.md +2 -0
  6. package/docs/12_desktop_clear_handoff_plan.md +3 -3
  7. package/docs/13_native_factory_diagnostics_plan.md +46 -0
  8. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +52 -0
  9. package/package.json +2 -2
  10. package/src/auditor-context.mjs +330 -0
  11. package/src/auditor-context.test.mjs +303 -0
  12. package/src/cli/auditor-context.mjs +141 -0
  13. package/src/cli/auditor-context.test.mjs +148 -0
  14. package/src/cli/codex-hook.mjs +27 -4
  15. package/src/cli/codex-hook.test.mjs +4 -0
  16. package/src/cli/codex-restore-smoke.mjs +2 -1
  17. package/src/cli/codex-restore-source-audit.mjs +1 -1
  18. package/src/cli/doctor.mjs +5 -1
  19. package/src/cli/factory-diagnostics.mjs +246 -0
  20. package/src/cli/factory-diagnostics.test.mjs +201 -0
  21. package/src/cli/runtime-errors.mjs +85 -0
  22. package/src/cli/runtime-errors.test.mjs +75 -0
  23. package/src/cli/trim.mjs +4 -4
  24. package/src/codex-handoff-model-smoke.mjs +2 -3
  25. package/src/codex-sidecar-cli.test.mjs +19 -8
  26. package/src/codex-sidecar.mjs +2 -5
  27. package/src/codex-sidecar.test.mjs +17 -9
  28. package/src/codex-thread-index.mjs +17 -2
  29. package/src/db.mjs +1 -1
  30. package/src/factory-diagnostics.mjs +118 -0
  31. package/src/factory-diagnostics.test.mjs +97 -0
  32. package/src/haiku-summarizer.mjs +3 -5
  33. package/src/haiku-summarizer.test.mjs +52 -47
  34. package/src/hook-entrypoints.test.mjs +3 -1
  35. package/src/phase0-spotter-contract.test.mjs +279 -0
  36. package/src/portable-spawn-sync.mjs +58 -0
  37. package/src/portable-spawn-sync.test.mjs +40 -0
  38. package/src/prompt-submit.mjs +2 -0
  39. package/src/runtime-error-hook.test.mjs +106 -0
  40. package/src/runtime-error-observer.mjs +8 -0
  41. package/src/runtime-error-store.mjs +595 -0
  42. package/src/runtime-error-store.test.mjs +307 -0
  43. package/src/session-start.mjs +2 -0
  44. package/src/test-env.mjs +59 -2
  45. package/src/turn-backfill.test.mjs +2 -2
  46. package/src/turn-processor.mjs +2 -0
  47. package/src/windows-acl-test-helper.mjs +29 -0
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import { recordRuntimeErrorBestEffort } from '../runtime-error-store.mjs';
2
+
1
3
  function parseArgs(argv) {
2
4
  const out = {
3
5
  event: null,
@@ -57,10 +59,9 @@ function parsePayload(raw) {
57
59
 
58
60
  function codexHomeFromTranscriptPath(transcriptPath) {
59
61
  if (typeof transcriptPath !== 'string') return null;
60
- const marker = `${process.platform === 'win32' ? '\\' : '/'}sessions${process.platform === 'win32' ? '\\' : '/'}`;
61
- const idx = transcriptPath.indexOf(marker);
62
- if (idx <= 0) return null;
63
- return transcriptPath.slice(0, idx);
62
+ const match = /[\\/]sessions[\\/]/.exec(transcriptPath);
63
+ if (!match || match.index <= 0) return null;
64
+ return transcriptPath.slice(0, match.index);
64
65
  }
65
66
 
66
67
  function suppressExperimentalWarnings() {
@@ -88,6 +89,21 @@ async function captureCodexHookSession({
88
89
  buildMonitorUsage = null,
89
90
  summarize = true,
90
91
  } = {}) {
92
+ if (isSpotterChildEnvironment(env)) {
93
+ return {
94
+ status: 'skipped',
95
+ reason: 'spotter_child_backend',
96
+ db,
97
+ identity: null,
98
+ projectPath: null,
99
+ codexHome: null,
100
+ captured: null,
101
+ summarized: null,
102
+ monitorState: null,
103
+ usage: null,
104
+ };
105
+ }
106
+
91
107
  const [
92
108
  { getDb },
93
109
  { captureCodexRolloutToDb },
@@ -202,6 +218,11 @@ async function captureCodexHookSession({
202
218
  };
203
219
  }
204
220
 
221
+ function isSpotterChildEnvironment(env = {}) {
222
+ return ['SPOTTER_PARENT_PID', 'SPOTTER_BACKEND', 'SPOTTER_CHILD_BACKEND']
223
+ .some((name) => typeof env?.[name] === 'string' && env[name].length > 0);
224
+ }
225
+
205
226
  export async function runCodexStopHook({
206
227
  args = {},
207
228
  payload = {},
@@ -346,6 +367,7 @@ export async function run(argv = []) {
346
367
  parsed = parseArgs(argv);
347
368
  payload = parsePayload(await readStdin());
348
369
  } catch (err) {
370
+ recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
349
371
  const msg = err instanceof Error ? err.message : 'unknown';
350
372
  process.stderr.write(`[codex-hook] ${msg}\n`);
351
373
  process.exit(1);
@@ -379,6 +401,7 @@ export async function run(argv = []) {
379
401
  }
380
402
  process.exit(result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
381
403
  } catch (err) {
404
+ recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
382
405
  const msg = err instanceof Error ? err.message : 'unknown';
383
406
  if (parsed.json) {
384
407
  process.stdout.write(
@@ -500,6 +500,10 @@ test('codex-hook stop skips cleanly when Codex thread id is unavailable', async
500
500
  test('codexHomeFromTranscriptPath infers CODEX_HOME from rollout path', () => {
501
501
  const path = '/tmp/codex-home/sessions/2026/05/06/rollout-2026-05-06T09-40-50-id.jsonl';
502
502
  assert.equal(_internal.codexHomeFromTranscriptPath(path), '/tmp/codex-home');
503
+ assert.equal(
504
+ _internal.codexHomeFromTranscriptPath('C:\\codex-home\\sessions\\2026\\05\\06\\rollout-2026-05-06T09-40-50-id.jsonl'),
505
+ 'C:\\codex-home',
506
+ );
503
507
  });
504
508
 
505
509
  test('parseArgs: accepts user-prompt-submit Codex hook event', () => {
@@ -326,7 +326,8 @@ export async function run(args) {
326
326
 
327
327
  if (parsed.json) process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
328
328
  else process.stdout.write(renderTextResult(payload) + '\n');
329
- process.exit(payload.status === 'app-server-restart-stable' && !payload.restoreSafetyRiskInspected ? 0 : 1);
329
+ process.exitCode =
330
+ payload.status === 'app-server-restart-stable' && !payload.restoreSafetyRiskInspected ? 0 : 1;
330
331
  }
331
332
 
332
333
  function buildRestoreTextNeedles(restoreSafety) {
@@ -296,7 +296,7 @@ export async function run(args) {
296
296
 
297
297
  if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
298
298
  else process.stdout.write(renderTextResult(result) + '\n');
299
- process.exit(result.status === 'restore-source-audit-complete' ? 0 : 1);
299
+ process.exitCode = result.status === 'restore-source-audit-complete' ? 0 : 1;
300
300
  }
301
301
 
302
302
  export const _internal = {
@@ -493,6 +493,9 @@ function readCodexHookDiagnosis(codexHome) {
493
493
  expectedPromptCommand,
494
494
  expectedPostToolUseCommand,
495
495
  hooksReadable: false,
496
+ hooksExists: existsSync(hooksPath),
497
+ configExists: existsSync(configPath),
498
+ configReadable: false,
496
499
  featureEnabled: false,
497
500
  codexHooksFeatureEnabled: false,
498
501
  hooksFeatureEnabled: false,
@@ -513,6 +516,7 @@ function readCodexHookDiagnosis(codexHome) {
513
516
  if (existsSync(configPath)) {
514
517
  try {
515
518
  const config = readFileSync(configPath, 'utf8');
519
+ out.configReadable = true;
516
520
  out.codexHooksFeatureEnabled = /^\s*codex_hooks\s*=\s*true\s*$/m.test(config);
517
521
  out.hooksFeatureEnabled = /^\s*hooks\s*=\s*true\s*$/m.test(config);
518
522
  out.featureEnabled = out.codexHooksFeatureEnabled || out.hooksFeatureEnabled;
@@ -522,7 +526,7 @@ function readCodexHookDiagnosis(codexHome) {
522
526
  }
523
527
  }
524
528
 
525
- if (!existsSync(hooksPath)) return out;
529
+ if (!out.hooksExists) return out;
526
530
  let parsed;
527
531
  try {
528
532
  parsed = JSON.parse(readFileSync(hooksPath, 'utf8'));
@@ -0,0 +1,246 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { createRequire } from 'node:module';
4
+
5
+ import { defaultAuditorContextDbPath } from '../auditor-context.mjs';
6
+ import { buildFactoryDiagnostics } from '../factory-diagnostics.mjs';
7
+ import { findCodexThreadCandidate, defaultCodexHome } from '../codex-thread-index.mjs';
8
+ import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
9
+ import { CURRENT_VERSION } from '../db.mjs';
10
+ import { _internal as doctorInternal } from './doctor.mjs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const PACKAGE_VERSION = require('../../package.json').version;
14
+ const REQUIRED_DATABASE_COLUMNS = {
15
+ sessions: ['session_id', 'project_path', 'status', 'created_at', 'updated_at', 'merged_into'],
16
+ skeletons: ['id', 'session_id', 'turn_number', 'role', 'summary', 'created_at', 'origin_session_id'],
17
+ bodies: ['id', 'session_id', 'origin_session_id', 'turn_number', 'role', 'text', 'token_count', 'created_at'],
18
+ details: ['id', 'session_id', 'turn_number', 'tool_name', 'input_text', 'output_text', 'token_count', 'created_at', 'origin_session_id', 'kind', 'source_id'],
19
+ handoff_batons: ['project_path', 'session_id', 'created_at'],
20
+ };
21
+ const REQUIRED_DATABASE_INDEXES = ['uq_skeletons_turn_v3', 'uq_details_source'];
22
+ const REQUIRED_INDEX_SHAPES = {
23
+ uq_skeletons_turn_v3: {
24
+ table: 'skeletons',
25
+ columns: ['session_id', 'origin_session_id', 'turn_number', 'role'],
26
+ partial: false,
27
+ },
28
+ uq_details_source: {
29
+ table: 'details',
30
+ columns: ['session_id', 'origin_session_id', 'source_id'],
31
+ partial: true,
32
+ },
33
+ };
34
+
35
+ export function parseArgs(argv = []) {
36
+ if (argv.length !== 1 || argv[0] !== '--json') throw new TypeError('usage error');
37
+ return { json: true };
38
+ }
39
+
40
+ export function inspectFactoryDatabase({
41
+ dbPath = defaultAuditorContextDbPath(),
42
+ threadId = null,
43
+ projectPath = null,
44
+ } = {}) {
45
+ if (!existsSync(dbPath)) {
46
+ return databaseResult('not_applicable', null, false);
47
+ }
48
+
49
+ let db;
50
+ try {
51
+ db = new DatabaseSync(dbPath, { readOnly: true });
52
+ const schemaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
53
+ if (schemaVersion !== CURRENT_VERSION || !hasFactoryDatabaseShape(db)) {
54
+ return databaseResult('not_ready', schemaVersion, false);
55
+ }
56
+ if (!threadId || typeof projectPath !== 'string' || projectPath.length === 0) {
57
+ return databaseResult('ready', schemaVersion, false);
58
+ }
59
+ const sessionId = `codex:${threadId}`;
60
+ const counts = db.prepare(
61
+ `SELECT
62
+ (SELECT COUNT(*) FROM skeletons WHERE session_id = :sessionId) AS l1,
63
+ (SELECT COUNT(*) FROM bodies WHERE session_id = :sessionId) AS l2,
64
+ (SELECT COUNT(*) FROM details WHERE session_id = :sessionId) AS l3
65
+ WHERE EXISTS (
66
+ SELECT 1 FROM sessions
67
+ WHERE session_id = :sessionId AND lower(project_path) = lower(:projectPath)
68
+ )`,
69
+ ).get({ sessionId, projectPath });
70
+ const handoffMemory = counts !== undefined &&
71
+ Number(counts.l1 ?? 0) + Number(counts.l2 ?? 0) + Number(counts.l3 ?? 0) > 0;
72
+ return databaseResult('ready', schemaVersion, handoffMemory);
73
+ } catch {
74
+ return databaseResult('unverified', null, false);
75
+ } finally {
76
+ db?.close();
77
+ }
78
+ }
79
+
80
+ function databaseResult(status, schemaVersion, handoffMemory) {
81
+ return {
82
+ status,
83
+ schemaVersion,
84
+ supportedSchemaVersion: CURRENT_VERSION,
85
+ handoffMemory,
86
+ };
87
+ }
88
+
89
+ function hasFactoryDatabaseShape(db) {
90
+ const tableInfo = {};
91
+ for (const [table, requiredColumns] of Object.entries(REQUIRED_DATABASE_COLUMNS)) {
92
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
93
+ tableInfo[table] = new Map(rows.map((row) => [row.name, row]));
94
+ const actual = new Set(tableInfo[table].keys());
95
+ if (requiredColumns.some((column) => !actual.has(column))) return false;
96
+ }
97
+ if (tableInfo.sessions.get('session_id')?.pk !== 1 ||
98
+ tableInfo.handoff_batons.get('project_path')?.pk !== 1) return false;
99
+ for (const [table, columns] of Object.entries({
100
+ sessions: ['project_path', 'status', 'created_at', 'updated_at'],
101
+ handoff_batons: ['session_id', 'created_at'],
102
+ })) {
103
+ if (columns.some((column) => tableInfo[table].get(column)?.notnull !== 1)) return false;
104
+ }
105
+
106
+ for (const name of REQUIRED_DATABASE_INDEXES) {
107
+ const expected = REQUIRED_INDEX_SHAPES[name];
108
+ const index = db.prepare(`PRAGMA index_list(${expected.table})`).all()
109
+ .find((row) => row.name === name);
110
+ if (!index || index.unique !== 1 || Boolean(index.partial) !== expected.partial) return false;
111
+ const columns = db.prepare(`PRAGMA index_info(${name})`).all().map((row) => row.name);
112
+ if (columns.length !== expected.columns.length ||
113
+ columns.some((column, indexPosition) => column !== expected.columns[indexPosition])) return false;
114
+ }
115
+
116
+ const bodiesUnique = db.prepare('PRAGMA index_list(bodies)').all().some((index) => {
117
+ if (index.unique !== 1) return false;
118
+ const columns = db.prepare(`PRAGMA index_info(${index.name})`).all().map((row) => row.name);
119
+ return columns.join('\0') === ['session_id', 'origin_session_id', 'turn_number', 'role'].join('\0');
120
+ });
121
+ return bodiesUnique;
122
+ }
123
+
124
+ function eventStatus({ hooks, legacyHooks, featureEnabled, expectedCommand, timeoutSec }) {
125
+ if (hooks.length === 0) return featureEnabled ? 'not_ready' : 'not_applicable';
126
+ if (!featureEnabled || legacyHooks.length > 0 || hooks.length !== 1) return 'not_ready';
127
+ const hook = hooks[0];
128
+ return hook.type === 'command' && hook.command === expectedCommand && hook.timeoutSec === timeoutSec &&
129
+ hook.async === false ? 'ready' : 'not_ready';
130
+ }
131
+
132
+ export function inspectFactoryHooks({ codexHome = defaultCodexHome(), readHooks = doctorInternal.readCodexHookDiagnosis } = {}) {
133
+ try {
134
+ const diagnosis = readHooks(codexHome);
135
+ if ((diagnosis.configExists && !diagnosis.configReadable) ||
136
+ (diagnosis.hooksExists && !diagnosis.hooksReadable)) {
137
+ return { status: 'unverified', reason: 'hook_configuration_unreadable', events: {} };
138
+ }
139
+ const events = {
140
+ userPromptSubmit: eventStatus({
141
+ hooks: diagnosis.managedPromptHooks,
142
+ legacyHooks: diagnosis.legacyManagedPromptHooks,
143
+ featureEnabled: diagnosis.featureEnabled,
144
+ expectedCommand: diagnosis.expectedPromptCommand,
145
+ timeoutSec: 30,
146
+ }),
147
+ postToolUse: eventStatus({
148
+ hooks: diagnosis.managedPostToolUseHooks,
149
+ legacyHooks: diagnosis.legacyManagedPostToolUseHooks,
150
+ featureEnabled: diagnosis.featureEnabled,
151
+ expectedCommand: diagnosis.expectedPostToolUseCommand,
152
+ timeoutSec: 30,
153
+ }),
154
+ stop: eventStatus({
155
+ hooks: diagnosis.managedStopHooks,
156
+ legacyHooks: diagnosis.legacyManagedStopHooks,
157
+ featureEnabled: diagnosis.featureEnabled,
158
+ expectedCommand: diagnosis.expectedStopCommand,
159
+ timeoutSec: 300,
160
+ }),
161
+ };
162
+ const values = Object.values(events);
163
+ return {
164
+ status: values.includes('not_ready')
165
+ ? 'not_ready'
166
+ : values.includes('ready')
167
+ ? 'unverified'
168
+ : 'not_applicable',
169
+ reason: 'hooks_inspected',
170
+ events,
171
+ };
172
+ } catch {
173
+ return { status: 'unverified', reason: 'hook_inspection_failed', events: {} };
174
+ }
175
+ }
176
+
177
+ export function inspectFactoryThread({
178
+ env = process.env,
179
+ cwd = process.cwd(),
180
+ codexHome = env.CODEX_HOME || defaultCodexHome(),
181
+ resolveIdentity = resolveCodexThreadIdentity,
182
+ findCandidate = findCodexThreadCandidate,
183
+ } = {}) {
184
+ const identity = resolveIdentity({ codexThreadId: null }, env);
185
+ if (!identity.codexThreadId) {
186
+ return {
187
+ status: 'not_applicable',
188
+ reason: 'codex_thread_not_detected',
189
+ rolloutAvailable: false,
190
+ threadId: null,
191
+ };
192
+ }
193
+ try {
194
+ const candidate = findCandidate({
195
+ threadId: identity.codexThreadId,
196
+ codexHome,
197
+ projectPath: cwd,
198
+ requireProjectMatch: true,
199
+ });
200
+ return candidate
201
+ ? { status: 'ready', reason: 'thread_and_rollout_detected', rolloutAvailable: true, threadId: identity.codexThreadId }
202
+ : { status: 'not_ready', reason: 'rollout_not_found_for_project', rolloutAvailable: false, threadId: null };
203
+ } catch {
204
+ return { status: 'unverified', reason: 'rollout_inspection_failed', rolloutAvailable: false, threadId: identity.codexThreadId };
205
+ }
206
+ }
207
+
208
+ export function collectFactoryDiagnostics({
209
+ env = process.env,
210
+ cwd = process.cwd(),
211
+ version = PACKAGE_VERSION,
212
+ inspectThread = inspectFactoryThread,
213
+ inspectDatabase = inspectFactoryDatabase,
214
+ inspectHooks = inspectFactoryHooks,
215
+ } = {}) {
216
+ const thread = inspectThread({ env, cwd });
217
+ const database = inspectDatabase({ threadId: thread.threadId ?? null, projectPath: cwd });
218
+ const hooks = inspectHooks({ codexHome: env.CODEX_HOME || defaultCodexHome() });
219
+ return buildFactoryDiagnostics({ version, database, hooks, thread });
220
+ }
221
+
222
+ export function run(argv = [], { stdout = process.stdout, ...dependencies } = {}) {
223
+ try {
224
+ parseArgs(argv);
225
+ } catch {
226
+ stdout.write(`${JSON.stringify({
227
+ schema: 'throughline.native_factory_diagnostics.v1',
228
+ version: PACKAGE_VERSION,
229
+ overall: { status: 'unverified' },
230
+ error: 'invalid_diagnostics_request',
231
+ })}\n`);
232
+ return 2;
233
+ }
234
+ try {
235
+ stdout.write(`${JSON.stringify(collectFactoryDiagnostics(dependencies))}\n`);
236
+ return 0;
237
+ } catch {
238
+ stdout.write(`${JSON.stringify({
239
+ schema: 'throughline.native_factory_diagnostics.v1',
240
+ version: PACKAGE_VERSION,
241
+ overall: { status: 'unverified' },
242
+ error: 'diagnostics_internal_error',
243
+ })}\n`);
244
+ return 1;
245
+ }
246
+ }