throughline 0.6.1 → 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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +14 -2
  3. package/bin/throughline.mjs +31 -0
  4. package/docs/00_overview.md +2 -0
  5. package/docs/04_public_release_plan.md +2 -0
  6. package/docs/13_native_factory_diagnostics_plan.md +46 -0
  7. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +52 -0
  8. package/package.json +2 -2
  9. package/src/auditor-context.test.mjs +8 -1
  10. package/src/cli/codex-hook.mjs +7 -4
  11. package/src/cli/codex-hook.test.mjs +4 -0
  12. package/src/cli/codex-restore-smoke.mjs +2 -1
  13. package/src/cli/codex-restore-source-audit.mjs +1 -1
  14. package/src/cli/doctor.mjs +5 -1
  15. package/src/cli/factory-diagnostics.mjs +246 -0
  16. package/src/cli/factory-diagnostics.test.mjs +201 -0
  17. package/src/cli/runtime-errors.mjs +85 -0
  18. package/src/cli/runtime-errors.test.mjs +75 -0
  19. package/src/cli/trim.mjs +4 -4
  20. package/src/codex-handoff-model-smoke.mjs +2 -3
  21. package/src/codex-sidecar-cli.test.mjs +19 -8
  22. package/src/codex-sidecar.mjs +2 -5
  23. package/src/codex-sidecar.test.mjs +17 -9
  24. package/src/codex-thread-index.mjs +7 -1
  25. package/src/db.mjs +1 -1
  26. package/src/factory-diagnostics.mjs +118 -0
  27. package/src/factory-diagnostics.test.mjs +97 -0
  28. package/src/haiku-summarizer.mjs +3 -5
  29. package/src/haiku-summarizer.test.mjs +52 -47
  30. package/src/hook-entrypoints.test.mjs +3 -1
  31. package/src/phase0-spotter-contract.test.mjs +6 -7
  32. package/src/portable-spawn-sync.mjs +58 -0
  33. package/src/portable-spawn-sync.test.mjs +40 -0
  34. package/src/prompt-submit.mjs +2 -0
  35. package/src/runtime-error-hook.test.mjs +106 -0
  36. package/src/runtime-error-observer.mjs +8 -0
  37. package/src/runtime-error-store.mjs +595 -0
  38. package/src/runtime-error-store.test.mjs +307 -0
  39. package/src/session-start.mjs +2 -0
  40. package/src/test-env.mjs +59 -2
  41. package/src/turn-processor.mjs +2 -0
  42. package/src/windows-acl-test-helper.mjs +29 -0
@@ -0,0 +1,97 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ FACTORY_DIAGNOSTICS_SCHEMA,
6
+ buildFactoryDiagnostics,
7
+ } from './factory-diagnostics.mjs';
8
+
9
+ test('factory diagnostics: 未設定の native factory は not_applicable を成功へ丸めない', () => {
10
+ const result = buildFactoryDiagnostics({
11
+ version: '0.6.1',
12
+ database: { status: 'not_applicable', reason: 'db_not_found', schemaVersion: null, handoffMemory: false },
13
+ hooks: { status: 'not_applicable', claudeStatus: 'not_applicable', reason: 'hooks_not_registered', events: {} },
14
+ thread: { status: 'not_applicable', reason: 'codex_thread_not_detected', rolloutAvailable: false },
15
+ });
16
+
17
+ assert.equal(result.schema, FACTORY_DIAGNOSTICS_SCHEMA);
18
+ assert.equal(result.overall.status, 'not_applicable');
19
+ assert.equal(result.readiness.capture.status, 'not_applicable');
20
+ assert.equal(result.readiness.restore.status, 'not_applicable');
21
+ assert.equal(result.readiness.handoff.status, 'not_applicable');
22
+ });
23
+
24
+ test('factory diagnostics: restore は実行していない smoke を ready にしない', () => {
25
+ const result = buildFactoryDiagnostics({
26
+ version: '0.6.1',
27
+ database: { status: 'ready', reason: 'db_schema_supported', schemaVersion: 8, handoffMemory: true },
28
+ hooks: {
29
+ status: 'ready',
30
+ claudeStatus: 'ready',
31
+ reason: 'managed_hooks_ready',
32
+ events: {
33
+ userPromptSubmit: 'ready',
34
+ postToolUse: 'ready',
35
+ stop: 'ready',
36
+ },
37
+ },
38
+ thread: { status: 'ready', reason: 'thread_and_rollout_detected', rolloutAvailable: true },
39
+ });
40
+
41
+ assert.equal(result.readiness.capture.status, 'ready');
42
+ assert.equal(result.readiness.handoff.status, 'ready');
43
+ assert.equal(result.readiness.restore.status, 'ready');
44
+ assert.equal(result.evidence.restoreSmoke.status, 'unverified');
45
+ assert.equal(result.overall.status, 'ready');
46
+ });
47
+
48
+ test('factory diagnostics: JSON に本文、秘密、絶対 path、生 state を含めない', () => {
49
+ const secret = 'sk-test-very-secret';
50
+ const body = 'ユーザーの prompt 本文';
51
+ const absolutePath = '/Users/example/.throughline/state/session.json';
52
+ const result = buildFactoryDiagnostics({
53
+ version: '0.6.1',
54
+ database: {
55
+ status: 'unverified',
56
+ reason: `db_open_failed:${secret}:${absolutePath}`,
57
+ schemaVersion: 8,
58
+ handoffMemory: false,
59
+ rawState: { body, secret, absolutePath },
60
+ },
61
+ hooks: { status: 'unverified', claudeStatus: 'unverified', reason: `config_unreadable:${absolutePath}`, events: {} },
62
+ thread: { status: 'unverified', reason: `rollout_unreadable:${body}`, rolloutAvailable: false },
63
+ });
64
+
65
+ const json = JSON.stringify(result);
66
+ assert.doesNotMatch(json, new RegExp(secret));
67
+ assert.doesNotMatch(json, new RegExp(body));
68
+ assert.doesNotMatch(json, new RegExp(absolutePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
69
+ assert.equal(result.databaseSchema.reason, 'diagnostic_unverified');
70
+ assert.equal(result.hooks.reason, 'diagnostic_unverified');
71
+ assert.equal(result.readiness.capture.reason, 'diagnostic_unverified');
72
+ });
73
+
74
+ test('factory diagnostics: known not_readyをunverifiedで隠さずDB schemaをoverallへ含める', () => {
75
+ const result = buildFactoryDiagnostics({
76
+ version: '0.6.1',
77
+ database: { status: 'not_ready', schemaVersion: 7, handoffMemory: false },
78
+ hooks: { status: 'ready', claudeStatus: 'ready', events: {} },
79
+ thread: { status: 'ready', rolloutAvailable: true },
80
+ });
81
+
82
+ assert.equal(result.databaseSchema.status, 'not_ready');
83
+ assert.equal(result.readiness.restore.status, 'not_ready');
84
+ assert.equal(result.overall.status, 'not_ready');
85
+ });
86
+
87
+ test('factory diagnostics: project不一致threadのmemoryをhandoff readyにしない', () => {
88
+ const result = buildFactoryDiagnostics({
89
+ version: '0.6.1',
90
+ database: { status: 'ready', schemaVersion: 8, supportedSchemaVersion: 8, handoffMemory: true },
91
+ hooks: { status: 'ready', claudeStatus: 'ready', events: {} },
92
+ thread: { status: 'not_ready', rolloutAvailable: false },
93
+ });
94
+
95
+ assert.equal(result.readiness.handoff.status, 'not_ready');
96
+ assert.equal(result.overall.status, 'not_ready');
97
+ });
@@ -36,7 +36,6 @@
36
36
  * 2. それでも失敗したら L2 全文を L1 に入れる(情報欠損ゼロ)
37
37
  */
38
38
 
39
- import { spawnSync } from 'child_process';
40
39
  import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
41
40
  import { join } from 'path';
42
41
  import { homedir, tmpdir } from 'os';
@@ -45,6 +44,7 @@ import {
45
44
  CODEX_SIDECAR_STATUS,
46
45
  runCodexSidecarCommand,
47
46
  } from './codex-sidecar.mjs';
47
+ import { spawnPortableSync } from './portable-spawn-sync.mjs';
48
48
 
49
49
  const MODEL = 'claude-haiku-4-5-20251001';
50
50
  const MAX_RETRIES = 2;
@@ -206,11 +206,10 @@ function summarizeWithHaiku(l2Text, prompt, env) {
206
206
 
207
207
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
208
208
  try {
209
- const result = spawnSync('claude', ['-p', '--model', MODEL, prompt], {
209
+ const result = spawnPortableSync('claude', ['-p', '--model', MODEL, prompt], {
210
210
  input: l2Text,
211
211
  encoding: 'utf8',
212
212
  timeout: TIMEOUT_MS,
213
- shell: process.platform === 'win32', // Windows は claude.cmd ラッパー
214
213
  env: childEnv,
215
214
  cwd: HAIKU_WORKDIR, // ← これが再帰防止の本丸
216
215
  });
@@ -247,7 +246,7 @@ function summarizeWithCodexCli(l2Text, { projectPath, env }) {
247
246
  const command = env.THROUGHLINE_CODEX_CLI_BIN ?? 'codex';
248
247
  const prompt = buildCodexPrompt(l2Text);
249
248
  const childEnv = { ...env, [CODEX_SUMMARIZER_GUARD_ENV]: '1' };
250
- const result = spawnSync(
249
+ const result = spawnPortableSync(
251
250
  command,
252
251
  [
253
252
  'exec',
@@ -265,7 +264,6 @@ function summarizeWithCodexCli(l2Text, { projectPath, env }) {
265
264
  input: l2Text,
266
265
  encoding: 'utf8',
267
266
  timeout: CODEX_CLI_TIMEOUT_MS,
268
- shell: process.platform === 'win32',
269
267
  env: childEnv,
270
268
  cwd: projectPath,
271
269
  },
@@ -2,14 +2,29 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { delimiter, join } from 'node:path';
6
6
  import { summarizeToL1 } from './haiku-summarizer.mjs';
7
7
 
8
8
  function makeBin(dir, name, body) {
9
- const path = join(dir, name);
10
- writeFileSync(path, body);
11
- chmodSync(path, 0o755);
12
- return path;
9
+ const script = join(dir, `${name}.mjs`);
10
+ writeFileSync(script, body);
11
+ if (process.platform === 'win32') {
12
+ const command = join(dir, `${name}.cmd`);
13
+ writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(script)} %*\r\n`);
14
+ writeFileSync(join(dir, `${name}.ps1`), `& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args\nexit $LASTEXITCODE\n`);
15
+ return command;
16
+ }
17
+ const command = join(dir, name);
18
+ writeFileSync(command, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
19
+ chmodSync(command, 0o755);
20
+ return command;
21
+ }
22
+
23
+ function envWithPrependedPath(dir) {
24
+ const env = { ...process.env };
25
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH';
26
+ env[pathKey] = `${dir}${delimiter}${env[pathKey] ?? ''}`;
27
+ return env;
13
28
  }
14
29
 
15
30
  test('summarizeToL1: returns empty fallback for blank input', () => {
@@ -44,13 +59,11 @@ test('summarizeToL1: uses codex-sidecar when diagnostics and run both succeed',
44
59
  const sidecar = makeBin(
45
60
  dir,
46
61
  'codex-sidecar',
47
- `#!/usr/bin/env bash
48
- if [ "$1" = "diagnostics" ]; then
49
- printf '{"status":"ok"}\\n'
50
- exit 0
51
- fi
52
- printf '{"status":"ok","summary":"sidecar summary"}\\n'
53
- exit 0
62
+ `if (process.argv[2] === 'diagnostics') {
63
+ process.stdout.write('{"status":"ok"}\\n');
64
+ } else {
65
+ process.stdout.write('{"status":"ok","summary":"sidecar summary"}\\n');
66
+ }
54
67
  `,
55
68
  );
56
69
 
@@ -78,13 +91,11 @@ test('summarizeToL1: accepts stable SidecarResult summary without status field',
78
91
  const sidecar = makeBin(
79
92
  dir,
80
93
  'codex-sidecar',
81
- `#!/usr/bin/env bash
82
- if [ "$1" = "diagnostics" ]; then
83
- printf '{"status":"ok"}\\n'
84
- exit 0
85
- fi
86
- printf '{"summary":"stable sidecar summary","confidence":{"level":"high"},"recommendedNextAction":"continue"}\\n'
87
- exit 0
94
+ `if (process.argv[2] === 'diagnostics') {
95
+ process.stdout.write('{"status":"ok"}\\n');
96
+ } else {
97
+ process.stdout.write('{"summary":"stable sidecar summary","confidence":{"level":"high"},"recommendedNextAction":"continue"}\\n');
98
+ }
88
99
  `,
89
100
  );
90
101
 
@@ -112,9 +123,8 @@ test('summarizeToL1: when sidecar is disabled, keeps current Haiku-compatible pa
112
123
  makeBin(
113
124
  dir,
114
125
  'claude',
115
- `#!/usr/bin/env bash
116
- cat >/dev/null
117
- printf 'haiku summary\\n'
126
+ `for await (const _chunk of process.stdin) {}
127
+ process.stdout.write('haiku summary\\n');
118
128
  `,
119
129
  );
120
130
 
@@ -122,8 +132,7 @@ printf 'haiku summary\\n'
122
132
  hostMode: 'claude-primary',
123
133
  projectPath: '/repo',
124
134
  env: {
125
- ...process.env,
126
- PATH: `${dir}:${process.env.PATH ?? ''}`,
135
+ ...envWithPrependedPath(dir),
127
136
  THROUGHLINE_CODEX_SIDECAR_DISABLED: '1',
128
137
  },
129
138
  });
@@ -143,21 +152,19 @@ test('summarizeToL1: sidecar run failure keeps current Haiku-compatible path', (
143
152
  const sidecar = makeBin(
144
153
  dir,
145
154
  'codex-sidecar',
146
- `#!/usr/bin/env bash
147
- if [ "$1" = "diagnostics" ]; then
148
- printf '{"status":"ok"}\\n'
149
- exit 0
150
- fi
151
- printf 'sidecar failed\\n' >&2
152
- exit 42
155
+ `if (process.argv[2] === 'diagnostics') {
156
+ process.stdout.write('{"status":"ok"}\\n');
157
+ } else {
158
+ process.stderr.write('sidecar failed\\n');
159
+ process.exit(42);
160
+ }
153
161
  `,
154
162
  );
155
163
  makeBin(
156
164
  dir,
157
165
  'claude',
158
- `#!/usr/bin/env bash
159
- cat >/dev/null
160
- printf 'haiku after sidecar failure\\n'
166
+ `for await (const _chunk of process.stdin) {}
167
+ process.stdout.write('haiku after sidecar failure\\n');
161
168
  `,
162
169
  );
163
170
 
@@ -165,8 +172,7 @@ printf 'haiku after sidecar failure\\n'
165
172
  hostMode: 'claude-primary',
166
173
  projectPath: '/repo',
167
174
  env: {
168
- ...process.env,
169
- PATH: `${dir}:${process.env.PATH ?? ''}`,
175
+ ...envWithPrependedPath(dir),
170
176
  THROUGHLINE_CODEX_SIDECAR_BIN: sidecar,
171
177
  },
172
178
  });
@@ -199,10 +205,12 @@ test('summarizeToL1: codex-primary uses Codex CLI backend', () => {
199
205
  const codex = makeBin(
200
206
  dir,
201
207
  'codex',
202
- `#!/usr/bin/env bash
203
- printf '%s\\n' "$@" > "${argsFile}"
204
- cat > "${stdinFile}"
205
- printf 'codex summary\\n'
208
+ `import { writeFileSync } from 'node:fs';
209
+ writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
210
+ let input = '';
211
+ for await (const chunk of process.stdin) input += chunk;
212
+ writeFileSync(${JSON.stringify(stdinFile)}, input);
213
+ process.stdout.write('codex summary\\n');
206
214
  `,
207
215
  );
208
216
 
@@ -244,16 +252,14 @@ test('summarizeToL1: codex-primary failure is not hidden by fallback', () => {
244
252
  const codex = makeBin(
245
253
  dir,
246
254
  'codex',
247
- `#!/usr/bin/env bash
248
- printf 'codex failed\\n' >&2
249
- exit 42
255
+ `process.stderr.write('codex failed\\n');
256
+ process.exit(42);
250
257
  `,
251
258
  );
252
259
  makeBin(
253
260
  dir,
254
261
  'claude',
255
- `#!/usr/bin/env bash
256
- printf 'should not run\\n'
262
+ `process.stdout.write('should not run\\n');
257
263
  `,
258
264
  );
259
265
 
@@ -263,8 +269,7 @@ printf 'should not run\\n'
263
269
  hostMode: 'codex-primary',
264
270
  projectPath: dir,
265
271
  env: {
266
- ...process.env,
267
- PATH: `${dir}:${process.env.PATH ?? ''}`,
272
+ ...envWithPrependedPath(dir),
268
273
  THROUGHLINE_CODEX_CLI_BIN: codex,
269
274
  },
270
275
  }),
@@ -408,7 +408,9 @@ test('process-turn subprocess backfills all completed logical turns from a multi
408
408
  }
409
409
  });
410
410
 
411
- test('session-start backfills a derived predecessor transcript without a state file', () => {
411
+ test('session-start backfills a derived predecessor transcript without a state file', {
412
+ skip: process.platform === 'win32' ? 'Windowsはstate file transcriptPath fallback契約' : undefined,
413
+ }, () => {
412
414
  const home = makeTempHome();
413
415
  const project = makeTempProject();
414
416
  const predecessorId = 'missing-stop-predecessor';
@@ -269,12 +269,11 @@ function snapshotSqliteFiles(path) {
269
269
  return [path, `${path}-wal`, `${path}-shm`].map((file) => {
270
270
  if (!existsSync(file)) return { file, exists: false };
271
271
  const stat = lstatSync(file);
272
- return {
273
- file,
274
- exists: true,
275
- size: stat.size,
276
- mtimeMs: stat.mtimeMs,
277
- bytes: readFileSync(file).toString('hex'),
278
- };
272
+ try {
273
+ return { file, exists: true, size: stat.size, mtimeMs: stat.mtimeMs, bytes: readFileSync(file).toString('hex') };
274
+ } catch (error) {
275
+ if (error?.code === 'EBUSY') return { file, exists: true, size: stat.size, mtimeMs: stat.mtimeMs, readError: 'EBUSY' };
276
+ throw error;
277
+ }
279
278
  });
280
279
  }
@@ -0,0 +1,58 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, delimiter, dirname, extname, isAbsolute, join } from 'node:path';
4
+
5
+ function windowsPath(env) {
6
+ return Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? '';
7
+ }
8
+
9
+ function pairedPowerShellShim(command) {
10
+ const extension = extname(command).toLowerCase();
11
+ if (extension !== '.cmd' && extension !== '.bat') return null;
12
+ const sibling = join(dirname(command), `${basename(command, extension)}.ps1`);
13
+ return existsSync(sibling) ? sibling : null;
14
+ }
15
+
16
+ function resolveWindowsCommand(command, env) {
17
+ const extension = extname(command).toLowerCase();
18
+ if (isAbsolute(command) || command.includes('\\') || command.includes('/')) {
19
+ return pairedPowerShellShim(command) ?? command;
20
+ }
21
+ for (const directory of windowsPath(env).split(delimiter).filter(Boolean)) {
22
+ for (const suffix of ['.exe', '.ps1', '.cmd', '.bat', '']) {
23
+ const candidate = join(directory, `${command}${suffix}`);
24
+ if (!existsSync(candidate)) continue;
25
+ return pairedPowerShellShim(candidate) ?? candidate;
26
+ }
27
+ }
28
+ return command;
29
+ }
30
+
31
+ export function spawnPortableSync(command, args, options = {}) {
32
+ const platform = options.platform ?? process.platform;
33
+ const spawnOptions = { ...options };
34
+ delete spawnOptions.platform;
35
+ spawnOptions.shell = false;
36
+
37
+ if (platform !== 'win32') return spawnSync(command, args, spawnOptions);
38
+
39
+ const env = spawnOptions.env ?? process.env;
40
+ const resolved = resolveWindowsCommand(command, env);
41
+ const extension = extname(resolved).toLowerCase();
42
+ if (['.js', '.cjs', '.mjs'].includes(extension)) {
43
+ return spawnSync(process.execPath, [resolved, ...args], spawnOptions);
44
+ }
45
+ if (extension === '.ps1') {
46
+ return spawnSync('powershell.exe', [
47
+ '-NoLogo',
48
+ '-NoProfile',
49
+ '-NonInteractive',
50
+ '-ExecutionPolicy',
51
+ 'Bypass',
52
+ '-File',
53
+ resolved,
54
+ ...args,
55
+ ], spawnOptions);
56
+ }
57
+ return spawnSync(resolved, args, spawnOptions);
58
+ }
@@ -0,0 +1,40 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawnPortableSync } from './portable-spawn-sync.mjs';
7
+
8
+ test('spawnPortableSync: Windows cmd shim preserves argv boundaries and stdin', {
9
+ skip: process.platform !== 'win32' ? 'Windows cmd shim contract' : undefined,
10
+ }, () => {
11
+ const dir = mkdtempSync(join(tmpdir(), 'tl-portable-spawn-'));
12
+ try {
13
+ const child = join(dir, 'child.mjs');
14
+ const command = join(dir, 'child.cmd');
15
+ const powerShellShim = join(dir, 'child.ps1');
16
+ writeFileSync(child, `
17
+ process.stdin.setEncoding('utf8');
18
+ let input = '';
19
+ process.stdin.on('data', (chunk) => { input += chunk; });
20
+ process.stdin.on('end', () => {
21
+ process.stdout.write(JSON.stringify({ args: process.argv.slice(2), input }));
22
+ });
23
+ `);
24
+ writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(child)} %*\r\n`);
25
+ writeFileSync(powerShellShim, `& ${JSON.stringify(process.execPath)} ${JSON.stringify(child)} @args\nexit $LASTEXITCODE\n`);
26
+
27
+ const result = spawnPortableSync(command, ['review prompt', 'a&b', '100%'], {
28
+ encoding: 'utf8',
29
+ input: 'stdin body',
30
+ });
31
+
32
+ assert.equal(result.status, 0, result.stderr || result.error?.message);
33
+ assert.deepEqual(JSON.parse(result.stdout), {
34
+ args: ['review prompt', 'a&b', '100%'],
35
+ input: 'stdin body',
36
+ });
37
+ } finally {
38
+ rmSync(dir, { recursive: true, force: true });
39
+ }
40
+ });
@@ -32,6 +32,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
32
32
  import { join, dirname } from 'node:path';
33
33
  import { homedir } from 'node:os';
34
34
  import { pathToFileURL } from 'node:url';
35
+ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
36
 
36
37
  // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
37
38
  const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
@@ -243,6 +244,7 @@ async function maybeRunPromptSpike({ payload, sessionId, projectPath }) {
243
244
 
244
245
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
245
246
  run().catch((err) => {
247
+ recordRuntimeErrorBestEffort('HOOK_PROMPT_SUBMIT_FAILED');
246
248
  const msg = err instanceof Error ? err.message : 'unknown';
247
249
  process.stderr.write(`[prompt-submit] error: ${msg}\n`);
248
250
  process.exit(1);
@@ -0,0 +1,106 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { defaultFactoryReporterConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
9
+ import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
10
+
11
+ const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
12
+
13
+ function createEnabledEnvironment(prefix) {
14
+ const root = mkdtempSync(join(tmpdir(), prefix));
15
+ const env = {
16
+ ...process.env,
17
+ HOME: root,
18
+ USERPROFILE: root,
19
+ LOCALAPPDATA: root,
20
+ XDG_CONFIG_HOME: join(root, 'config'),
21
+ XDG_STATE_HOME: join(root, 'state'),
22
+ };
23
+ const configPath = defaultFactoryReporterConfigPath(env);
24
+ mkdirSync(dirname(configPath), { recursive: true });
25
+ writeFileSync(configPath, JSON.stringify({
26
+ schema_version: '1.0',
27
+ host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
28
+ collection: { enabled: true },
29
+ reporting: { enabled: false },
30
+ }));
31
+ applyWindowsPrivateAcl(configPath);
32
+ return { root, env };
33
+ }
34
+
35
+ test('top-level hook owners record one fixed aggregate per failure without replacing hook failure', () => {
36
+ const { env } = createEnabledEnvironment('throughline-runtime-hook-');
37
+ const cases = [
38
+ ['session-start'],
39
+ ['prompt-submit'],
40
+ ['process-turn'],
41
+ ['codex-hook', 'stop'],
42
+ ];
43
+ for (const args of cases) {
44
+ const result = spawnSync(process.execPath, [BIN, ...args], {
45
+ env,
46
+ input: '{invalid-json',
47
+ encoding: 'utf8',
48
+ });
49
+ assert.notEqual(result.status, 0, args.join(' '));
50
+ assert.notEqual(result.stderr, '', args.join(' '));
51
+ assert.doesNotMatch(result.stderr, /store_unavailable/);
52
+ }
53
+
54
+ const storePath = defaultRuntimeErrorStorePath(env);
55
+ let store = JSON.parse(readFileSync(storePath, 'utf8'));
56
+ assert.equal(store.records.length, 4);
57
+ assert.deepEqual(store.records.map((record) => record.error_code).sort(), [
58
+ 'HOOK_CODEX_FAILED',
59
+ 'HOOK_PROCESS_TURN_FAILED',
60
+ 'HOOK_PROMPT_SUBMIT_FAILED',
61
+ 'HOOK_SESSION_START_FAILED',
62
+ ]);
63
+ assert.ok(store.records.every((record) => record.count === 1));
64
+
65
+ spawnSync(process.execPath, [BIN, 'process-turn'], {
66
+ env,
67
+ input: '{invalid-json',
68
+ encoding: 'utf8',
69
+ });
70
+ store = JSON.parse(readFileSync(storePath, 'utf8'));
71
+ assert.equal(store.records.find((record) => record.error_code === 'HOOK_PROCESS_TURN_FAILED').count, 2);
72
+ });
73
+
74
+ test('store failure preserves product failure and emits only fixed storage diagnostic', () => {
75
+ const { env } = createEnabledEnvironment('throughline-runtime-hook-store-fail-');
76
+ const storePath = defaultRuntimeErrorStorePath(env);
77
+ mkdirSync(dirname(storePath), { recursive: true });
78
+ writeFileSync(storePath, '{broken');
79
+
80
+ const result = spawnSync(process.execPath, [BIN, 'prompt-submit'], {
81
+ env,
82
+ input: '{invalid-json',
83
+ encoding: 'utf8',
84
+ });
85
+ assert.notEqual(result.status, 0);
86
+ assert.match(result.stderr, /store_unavailable/);
87
+ assert.match(result.stderr, /SyntaxError|JSON/);
88
+ assert.doesNotMatch(result.stderr, /runtime error store schema invalid/);
89
+ });
90
+
91
+ test('FIFO config cannot block the original hook failure', { skip: process.platform === 'win32' }, () => {
92
+ const { env } = createEnabledEnvironment('throughline-runtime-hook-fifo-');
93
+ const config = defaultFactoryReporterConfigPath(env);
94
+ execFileSync('rm', ['-f', config]);
95
+ execFileSync('mkfifo', [config]);
96
+ const started = Date.now();
97
+ const result = spawnSync(process.execPath, [BIN, 'prompt-submit'], {
98
+ env,
99
+ input: '{invalid-json',
100
+ encoding: 'utf8',
101
+ timeout: 2_000,
102
+ });
103
+ assert.notEqual(result.status, 0);
104
+ assert(Date.now() - started < 1_500);
105
+ assert.match(result.stderr, /SyntaxError|JSON/);
106
+ });
@@ -0,0 +1,8 @@
1
+ import { observeRuntimeError } from './runtime-error-store.mjs';
2
+
3
+ try {
4
+ const result = observeRuntimeError({ code: process.argv[2] });
5
+ process.exitCode = result.status === 'disabled' ? 3 : 0;
6
+ } catch {
7
+ process.exitCode = 1;
8
+ }