claude-spotter 1.4.22 → 1.4.23

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/CHANGELOG.md CHANGED
@@ -1,8 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.23 — release candidate (pending)
4
+
5
+ - **Factory diagnostics と local runtime error store。** factory diagnostics と
6
+ `spotter diagnostics runtime-errors` の公開候補。収集は canonical dotagents config の
7
+ JSON boolean `collection.enabled: true` が明示された時だけ有効で既定OFF、保存は
8
+ local-only、network送信は行わない。npm公開、tag、CI、registry由来installの確認は未実施。
9
+ - **隔離observerのdeadlineを実測へ整合。** 並列full-suite負荷でcold workerとreceipt
10
+ reconcilerが500msに収まらず、commit済みreceiptを`store_unavailable`へ誤分類するraceを
11
+ 再現した。絶対deadlineを1.5秒へ広げたうえでreceipt reconcilerを観測workerと
12
+ 並行prewarmし、後段cold-startの競合を除去。worker treeの強制終了と本体処理を
13
+ 止めない契約は維持する。
14
+
3
15
  ## 1.4.22 — 2026-07-13
4
16
 
5
17
  - **default-on裁定の文書修正。** v1.4.21の実装どおりdefault-onは確定事項であり、7日・30 fresh resultの測定は精度改善にだけ使う。ON/OFFの再審査やdefault-off rollbackを選択肢としていた誤記をREADME、正典、SLO、計画、課題台帳から削除した。
18
+ - **公開。** 478 tests(476 pass / 2 skip)、CI 6/6 green。公開commit`3c7c820`をnpm `claude-spotter@1.4.22`、tag / GitHub Release、registry由来global installへ同期した。
6
19
 
7
20
  ## 1.4.21 — 2026-07-13
8
21
 
package/README.md CHANGED
@@ -80,7 +80,7 @@ spotter codex-hook install
80
80
 
81
81
  ## Requirements
82
82
 
83
- - **Node.js 22.5+**
83
+ - **Node.js 22.13+**
84
84
  - **Claude Code 2.0+**
85
85
  - **Codex CLI** for the default Codex-native backend and the preferred Claude-host auditor path. The auto-selected Codex backend does not fall back to Haiku after a runtime failure
86
86
  - **Claude Max plan** only when a Claude host selects the Haiku path (Codex CLI is absent or `SPOTTER_AUDITOR_BACKEND=haiku` is explicit)
@@ -214,6 +214,8 @@ spotter db rebuild # wipe Claude local + Claude global DBs and refresh fro
214
214
  spotter status # list running daemons
215
215
  spotter doctor # environment check (Node / claude CLI / Codex readiness / tool-db integrity)
216
216
  spotter diagnostics logs # summarize daemon logs for pass=false / backend latency / anomaly signals
217
+ spotter diagnostics runtime-errors
218
+ # print the local allow-listed runtime-error aggregate snapshot (no network)
217
219
  spotter codex risk-check --findings findings.json --host-agent claude
218
220
  # run read-only codex-sidecar risk analysis for Spotter findings
219
221
  spotter codex review|explore|opinion --findings findings.json --host-agent claude
@@ -243,6 +245,38 @@ in a detached process. Hook responses do not wait for Codex. Add
243
245
  Primary auditor backend policy: Claude hooks automatically select Codex CLI when it is available on PATH,
244
246
  otherwise the Haiku-compatible path. Codex native hooks automatically select Codex CLI. An explicit
245
247
  `SPOTTER_AUDITOR_BACKEND` override wins on either host; runtime failure never triggers a hidden fallback.
248
+
249
+ ## Local runtime error aggregates
250
+
251
+ **v1.4.23 は release candidate(公開待ち)です。** 以下の factory diagnostics と
252
+ local runtime error aggregate は collection が既定OFFで、公開・tag・CI・registry由来
253
+ installの確認はいずれも未完了です。
254
+
255
+ Spotter collects fixed-code runtime failures only when the canonical dotagents factory reporter
256
+ configuration contains the JSON boolean `collection.enabled: true`. Missing, malformed, and disabled
257
+ configuration all fail closed. Collection is local-only: Spotter has no reporting credential or network
258
+ transport code. The owner-private atomic store contains only fixed templates and allow-listed aggregates;
259
+ raw exceptions, stdout/stderr, stacks, prompts, hook payloads, findings, file contents, and absolute paths
260
+ are not accepted by its API.
261
+
262
+ Daemon and direct Codex-hook owner boundaries perform collection in a killable child-process group with
263
+ a bounded timeout. A blocked FIFO or descendant therefore cannot stall the hook or daemon. HTTP reporting
264
+ metadata, when present in the shared config, is accepted only when `new URL(value).href === value` and the
265
+ scheme is HTTP(S); Spotter still neither reads credentials nor sends the aggregate anywhere.
266
+
267
+ On POSIX, every config/store read revalidates the current uid and exact `0600` file / `0700` directory
268
+ modes. Store mutations use a private SQLite `BEGIN IMMEDIATE` mutex that the OS releases on process crash;
269
+ there is no PID/mtime stale-owner reclaim path. On Windows, every
270
+ store access rebuilds the DACL to one FullControl ACE for the current process SID and verifies the ACL
271
+ readback before use.
272
+
273
+ `spotter diagnostics runtime-errors` emits the read-only cursor snapshot. Its
274
+ `ack`, `resolve`, `reopen`, and `compact` actions are the machine lifecycle
275
+ surface used after report acceptance. `spotter diagnostics logs`
276
+ and `spotter diagnostics factory` include only bounded store counts/status, never the store/config path
277
+ or record payload. Programmatic consumers can import `readRuntimeErrorSnapshot`,
278
+ `acknowledgeRuntimeErrors`, `resolveRuntimeError`, `reopenRuntimeError`, and `compactRuntimeErrors`.
279
+ Acknowledgement is monotonic, and compaction never removes an unacknowledged record.
246
280
  The Codex SessionStart hook refreshes `.spotter/tool-db.codex.json` in the background
247
281
  without touching the Claude DB.
248
282
  Codex CLI auditor child processes use a versioned product policy. The production selection is
package/bin/spotter.mjs CHANGED
@@ -11,6 +11,7 @@ import { runCodexCommand } from '../src/cli/codex-cmd.mjs';
11
11
  import { runCodexHookCommand } from '../src/cli/codex-hook-cmd.mjs';
12
12
  import { runAuditorCommand } from '../src/cli/auditor-cmd.mjs';
13
13
  import { runDiagnosticsCommand } from '../src/cli/diagnostics-cmd.mjs';
14
+ import { runFactoryDiagnostics } from '../src/cli/factory-diagnostics.mjs';
14
15
  import { runDaemonStart } from '../src/cli/daemon-cmd.mjs';
15
16
  import { runSessionStart } from '../src/hooks/session-start.mjs';
16
17
  import { runUserPrompt } from '../src/hooks/user-prompt.mjs';
@@ -42,6 +43,8 @@ Usage:
42
43
  spotter status show running daemons
43
44
  spotter doctor environment diagnostic
44
45
  spotter diagnostics logs [--json] summarize daemon logs for precision diagnostics
46
+ spotter diagnostics runtime-errors [snapshot|ack|resolve|reopen|compact]
47
+ consume the local allow-listed aggregate store
45
48
  spotter codex risk-check --findings FILE
46
49
  run read-only codex-sidecar risk analysis
47
50
  spotter codex review|explore|opinion --findings FILE
@@ -114,6 +117,11 @@ async function main() {
114
117
  await runAuditorCommand({ argv: rest });
115
118
  return;
116
119
  case 'diagnostics':
120
+ if (rest[0] === 'factory') {
121
+ if (rest.length !== 1) throw invalidFactoryDiagnosticsArgs();
122
+ process.stdout.write(`${JSON.stringify(await runFactoryDiagnostics())}\n`);
123
+ return;
124
+ }
117
125
  await runDiagnosticsCommand({ argv: rest });
118
126
  return;
119
127
  case 'daemon': {
@@ -197,6 +205,13 @@ function invalidInstallArgs() {
197
205
  return err;
198
206
  }
199
207
 
208
+ function invalidFactoryDiagnosticsArgs() {
209
+ const err = new Error('usage: spotter diagnostics factory');
210
+ err.stack = '';
211
+ err.exitCode = 2;
212
+ return err;
213
+ }
214
+
200
215
  main().catch((err) => {
201
216
  process.stderr.write(`spotter: ${err.stack || err.message || err}\n`);
202
217
  process.exit(err.exitCode ?? 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.4.22",
3
+ "version": "1.4.23",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "homepage": "https://github.com/kitepon-rgb/Spotter#readme",
36
36
  "engines": {
37
- "node": ">=22.5.0"
37
+ "node": ">=22.13.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "js-yaml": "^4.1.0"
@@ -32,6 +32,7 @@ import {
32
32
  loadAuditorContext,
33
33
  readProjectAuditorContextConfig,
34
34
  } from '../core/auditor-context.mjs';
35
+ import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
35
36
 
36
37
  const HERE = dirname(fileURLToPath(import.meta.url));
37
38
  const PACKAGE_ROOT = resolve(HERE, '..', '..');
@@ -77,11 +78,11 @@ export async function runCodexHookCommand({ argv = process.argv.slice(2) } = {})
77
78
  return;
78
79
  }
79
80
  if (sub === 'user-prompt-submit') {
80
- await runCodexUserPromptSubmitHook();
81
+ await runCodexUserPromptSubmitHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
81
82
  return;
82
83
  }
83
84
  if (sub === 'stop') {
84
- await runCodexStopHook();
85
+ await runCodexStopHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
85
86
  return;
86
87
  }
87
88
  process.stderr.write(`unknown codex-hook subcommand: ${sub}\n${CODEX_HOOK_USAGE}`);
@@ -121,6 +122,7 @@ export async function runCodexUserPromptSubmitHook({
121
122
  loadAuditorContextFn = loadAuditorContext,
122
123
  writeOutput = (text) => process.stdout.write(text),
123
124
  writeError = (text) => process.stderr.write(text),
125
+ runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
124
126
  } = {}) {
125
127
  if (isChildCall()) return;
126
128
  const input = await readInput();
@@ -160,6 +162,7 @@ export async function runCodexUserPromptSubmitHook({
160
162
  });
161
163
  contextDurationMs = Date.now() - contextStartedAt;
162
164
  } catch (err) {
165
+ await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
163
166
  contextDurationMs = Date.now() - contextStartedAt;
164
167
  const failure = projectBackendFailure(err?.code);
165
168
  safeWriteError(writeError, failure.stderr);
@@ -179,6 +182,7 @@ export async function runCodexUserPromptSubmitHook({
179
182
  return;
180
183
  }
181
184
  if (context.status === 'unavailable' || context.status === 'schema_mismatch') {
185
+ await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
182
186
  const failure = projectBackendFailure(context.status === 'unavailable'
183
187
  ? 'E_AUDITOR_CONTEXT_UNAVAILABLE'
184
188
  : 'E_AUDITOR_CONTEXT_SCHEMA');
@@ -219,8 +223,10 @@ export async function runCodexUserPromptSubmitHook({
219
223
  let catalog;
220
224
  let backend;
221
225
  let judgment;
226
+ let enteredAuditorBoundary = false;
222
227
  try {
223
228
  catalog = await readLocalFn({ projectRoot, hostAgent: 'codex' });
229
+ enteredAuditorBoundary = true;
224
230
  backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
225
231
  judgment = await backend.judge({
226
232
  stage: 'user_input',
@@ -229,6 +235,7 @@ export async function runCodexUserPromptSubmitHook({
229
235
  contextStatus: 'fresh',
230
236
  });
231
237
  } catch (err) {
238
+ if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
232
239
  const failure = projectBackendFailure(err?.code);
233
240
  safeWriteError(writeError, failure.stderr);
234
241
  await recordCodexHookEventSafe(recordHookEventFn, {
@@ -281,6 +288,7 @@ export async function runCodexStopHook({
281
288
  recordHookEventFn = appendCodexHookEvent,
282
289
  writeOutput = (text) => process.stdout.write(text),
283
290
  writeError = (text) => process.stderr.write(text),
291
+ runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
284
292
  } = {}) {
285
293
  if (isChildCall()) return;
286
294
  const input = await readInput();
@@ -331,11 +339,14 @@ export async function runCodexStopHook({
331
339
  let catalog;
332
340
  let backend;
333
341
  let judgment;
342
+ let enteredAuditorBoundary = false;
334
343
  try {
335
344
  catalog = await readLocalFn({ projectRoot, hostAgent: 'codex' });
345
+ enteredAuditorBoundary = true;
336
346
  backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
337
347
  judgment = await backend.judge({ stage: 'turn_end', finalResponse, usedTools });
338
348
  } catch (err) {
349
+ if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
339
350
  const failure = projectBackendFailure(err?.code);
340
351
  reportError(failure.stderr);
341
352
  writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
@@ -653,6 +664,14 @@ function createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBack
653
664
  });
654
665
  }
655
666
 
667
+ async function observeRuntimeFailure(observer, kind) {
668
+ try {
669
+ await observer(kind);
670
+ } catch {
671
+ // Runtime error telemetry must not alter hook output or exit behavior.
672
+ }
673
+ }
674
+
656
675
  function resolveCodexHookAuditorBackend({ env }) {
657
676
  if (!env?.SPOTTER_AUDITOR_BACKEND) return 'codex-cli';
658
677
  return selectAuditorBackend({ hostAgent: 'codex', env }).backend;
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { open } from 'node:fs/promises';
7
7
  import { writeFileSync } from 'node:fs';
8
+ import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
8
9
 
9
10
  function parseArgs(argv) {
10
11
  const out = { sessionId: null, projectRoot: null };
@@ -69,7 +70,12 @@ export async function runDaemonStart({ argv }) {
69
70
  // removed along with the stateless regime that required it.
70
71
  // v0.7.0: projectRoot drives tool-db loading (replaces the old tools.yaml catalog).
71
72
  // v0.12.0: orphan-cleanup is heartbeat-based inside startDaemon (no parent-PID arg).
72
- running = await startDaemon({ sessionId, projectRoot, logFn: log });
73
+ running = await startDaemon({
74
+ sessionId,
75
+ projectRoot,
76
+ logFn: log,
77
+ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
78
+ });
73
79
  } catch (err) {
74
80
  if (err instanceof DaemonAlreadyRunningError) {
75
81
  // v0.2 PID-preexist layer: a sibling daemon already serves this session.
@@ -1,11 +1,23 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { defaultDaemonLogDir, summarizeDaemonLogs } from '../core/daemon-log-diagnostics.mjs';
3
3
  import { summarizeHookEvents } from '../core/hook-event-log.mjs';
4
+ import {
5
+ acknowledgeRuntimeErrors,
6
+ compactRuntimeErrors,
7
+ readRuntimeErrorSnapshot,
8
+ readRuntimeErrorStoreStatus,
9
+ reopenRuntimeError,
10
+ resolveRuntimeError,
11
+ } from '../core/runtime-error-store.mjs';
4
12
 
5
13
  const DIAGNOSTICS_USAGE = `spotter diagnostics — read-only operational diagnostics
6
14
 
7
15
  Usage:
8
16
  spotter diagnostics logs [--log-dir DIR] [--project DIR] [--json]
17
+ spotter diagnostics runtime-errors [snapshot] [--after-cursor N] [--limit N]
18
+ spotter diagnostics runtime-errors ack CURSOR
19
+ spotter diagnostics runtime-errors resolve|reopen FINGERPRINT
20
+ spotter diagnostics runtime-errors compact
9
21
 
10
22
  --log-dir daemon log directory (default: ~/.spotter/runtime)
11
23
  --project project root for hook-events.jsonl (default: cwd)
@@ -17,6 +29,10 @@ export async function runDiagnosticsCommand({ argv = process.argv.slice(2) } = {
17
29
  await runDiagnosticsLogsCommand({ argv: argv.slice(1) });
18
30
  return;
19
31
  }
32
+ if (sub === 'runtime-errors') {
33
+ await runRuntimeErrorDiagnosticsCommand({ argv: argv.slice(1) });
34
+ return;
35
+ }
20
36
  process.stderr.write(`unknown diagnostics subcommand: ${sub}\n${DIAGNOSTICS_USAGE}`);
21
37
  process.exit(2);
22
38
  }
@@ -25,6 +41,7 @@ export async function runDiagnosticsLogsCommand({
25
41
  argv = [],
26
42
  summarizeDaemonLogsFn = summarizeDaemonLogs,
27
43
  summarizeHookEventsFn = summarizeHookEvents,
44
+ readRuntimeErrorStoreStatusFn = readRuntimeErrorStoreStatus,
28
45
  writeOutput = (text) => process.stdout.write(text),
29
46
  } = {}) {
30
47
  const opts = parseLogsArgs(argv);
@@ -33,7 +50,8 @@ export async function runDiagnosticsLogsCommand({
33
50
  // the hook-side observations (skip reasons, drained pending counts, transport errors
34
51
  // that never reach the daemon) surface in the same diagnostics output.
35
52
  const hookEvents = await summarizeHookEventsFn({ projectRoot: opts.projectRoot });
36
- const merged = { ...summary, hookEvents };
53
+ const runtimeErrors = await readRuntimeErrorStoreStatusFn();
54
+ const merged = { ...summary, hookEvents, runtimeErrors };
37
55
  if (opts.json) {
38
56
  writeOutput(JSON.stringify(merged, null, 2) + '\n');
39
57
  return;
@@ -110,9 +128,78 @@ export function formatDaemonLogSummary(summary) {
110
128
  }
111
129
  }
112
130
 
131
+ const runtimeErrors = summary.runtimeErrors;
132
+ if (runtimeErrors) {
133
+ lines.push(
134
+ ` runtime-errors: collection=${runtimeErrors.collection}, store=${runtimeErrors.store}, records=${runtimeErrors.records}, open=${runtimeErrors.open}, resolved=${runtimeErrors.resolved}, unacknowledged=${runtimeErrors.unacknowledged}`
135
+ );
136
+ }
137
+
113
138
  return lines.join('\n') + '\n';
114
139
  }
115
140
 
141
+ export async function runRuntimeErrorDiagnosticsCommand({
142
+ argv = [],
143
+ readSnapshotFn = readRuntimeErrorSnapshot,
144
+ acknowledgeFn = acknowledgeRuntimeErrors,
145
+ resolveFn = resolveRuntimeError,
146
+ reopenFn = reopenRuntimeError,
147
+ compactFn = compactRuntimeErrors,
148
+ writeOutput = (text) => process.stdout.write(text),
149
+ } = {}) {
150
+ const action = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'snapshot';
151
+ const actionArgs = action === 'snapshot' && argv[0] !== 'snapshot' ? argv : argv.slice(1);
152
+ if (!['snapshot', 'ack', 'resolve', 'reopen', 'compact'].includes(action)) throw runtimeErrorUsage();
153
+ try {
154
+ if (action === 'ack') {
155
+ if (actionArgs.length !== 1) throw runtimeErrorUsage();
156
+ const result = await acknowledgeFn({ cursor: parseNonNegativeInteger(actionArgs[0], 'cursor') });
157
+ writeOutput(`${JSON.stringify(result)}\n`);
158
+ return;
159
+ }
160
+ if (action === 'resolve' || action === 'reopen') {
161
+ if (actionArgs.length !== 1 || !/^[a-f0-9]{64}$/.test(actionArgs[0])) throw runtimeErrorUsage();
162
+ const result = await (action === 'resolve' ? resolveFn : reopenFn)({ fingerprint: actionArgs[0] });
163
+ writeOutput(`${JSON.stringify(result)}\n`);
164
+ return;
165
+ }
166
+ if (action === 'compact') {
167
+ if (actionArgs.length !== 0) throw runtimeErrorUsage();
168
+ writeOutput(`${JSON.stringify(await compactFn())}\n`);
169
+ return;
170
+ }
171
+ let afterCursor = 0;
172
+ let limit = 100;
173
+ for (let index = 0; index < actionArgs.length; index += 1) {
174
+ const arg = actionArgs[index];
175
+ if (arg === '--after-cursor') {
176
+ afterCursor = parseNonNegativeInteger(requireValue(actionArgs, (index += 1), '--after-cursor'), '--after-cursor');
177
+ continue;
178
+ }
179
+ if (arg === '--limit') {
180
+ limit = parsePositiveInteger(requireValue(actionArgs, (index += 1), '--limit'), '--limit');
181
+ continue;
182
+ }
183
+ throw Object.assign(new Error(`unknown diagnostics runtime-errors option: ${arg}`), { exitCode: 2 });
184
+ }
185
+ const snapshot = await readSnapshotFn({ afterCursor, limit });
186
+ writeOutput(`${JSON.stringify(snapshot)}\n`);
187
+ } catch (error) {
188
+ if (error?.exitCode === 2) throw error;
189
+ const fixed = new Error('runtime error store unavailable');
190
+ fixed.stack = '';
191
+ fixed.exitCode = 1;
192
+ throw fixed;
193
+ }
194
+ }
195
+
196
+ function runtimeErrorUsage() {
197
+ const error = new Error('usage: spotter diagnostics runtime-errors snapshot|ack|resolve|reopen|compact');
198
+ error.stack = '';
199
+ error.exitCode = 2;
200
+ return error;
201
+ }
202
+
116
203
  function formatCounter(counter) {
117
204
  if (!counter || typeof counter !== 'object') return '';
118
205
  const entries = Object.entries(counter).sort(([a], [b]) => a.localeCompare(b));
@@ -155,6 +242,16 @@ function requireValue(argv, index, option) {
155
242
  return value;
156
243
  }
157
244
 
245
+ function parseNonNegativeInteger(value, option) {
246
+ if (!/^(?:0|[1-9][0-9]*)$/.test(value)) throw Object.assign(new Error(`${option} requires a non-negative integer`), { exitCode: 2 });
247
+ return Number(value);
248
+ }
249
+
250
+ function parsePositiveInteger(value, option) {
251
+ if (!/^[1-9][0-9]*$/.test(value)) throw Object.assign(new Error(`${option} requires a positive integer`), { exitCode: 2 });
252
+ return Number(value);
253
+ }
254
+
158
255
  function topCounter(counter, limit) {
159
256
  return Object.entries(counter)
160
257
  .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
@@ -0,0 +1,178 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { codexHookDiagnostics } from './codex-hook-cmd.mjs';
4
+ import { inspectAuditorContextConfiguration } from './doctor.mjs';
5
+ import { loadDb, localDbPath } from '../tool-db/loader.mjs';
6
+ import { version } from '../version.mjs';
7
+ import { readRuntimeErrorStoreStatus } from '../core/runtime-error-store.mjs';
8
+
9
+ const KNOWN_MARKER_VERSIONS = new Set(['1', '2']);
10
+ const SAFE_CONTEXT_MODES = new Set(['disabled', 'throughline']);
11
+ const SAFE_CODEX_READINESS = new Set([
12
+ 'configured-unverified',
13
+ 'misconfigured',
14
+ 'not-installed',
15
+ 'unavailable',
16
+ ]);
17
+
18
+ export async function runFactoryDiagnostics({
19
+ projectRoot = process.cwd(),
20
+ codexHookDiagnosticsFn = codexHookDiagnostics,
21
+ inspectAuditorContextFn = inspectAuditorContextConfiguration,
22
+ readRuntimeErrorStoreStatusFn = readRuntimeErrorStoreStatus,
23
+ } = {}) {
24
+ const runtimeErrorStore = await readRuntimeErrorStoreStatusFn();
25
+ const markerResult = await readMarker(join(projectRoot, '.spotter', 'marker.json'));
26
+ if (markerResult.status === 'missing') return inactiveSnapshot(runtimeErrorStore);
27
+
28
+ const checks = [];
29
+ if (markerResult.status !== 'valid') {
30
+ checks.push(check('project_activation', 'unverified', markerResult.reasonCode));
31
+ return snapshot({ overallStatus: 'unverified', checks, runtimeErrorStore });
32
+ }
33
+
34
+ const marker = markerResult.value;
35
+ checks.push(check('project_activation', 'pass'));
36
+
37
+ const markerVersion = typeof marker.markerVersion === 'string' && KNOWN_MARKER_VERSIONS.has(marker.markerVersion)
38
+ ? marker.markerVersion
39
+ : null;
40
+ checks.push(markerVersion
41
+ ? check('marker_schema', 'pass')
42
+ : check('marker_schema', 'fail', 'unsupported_marker_schema'));
43
+
44
+ const contextMode = safeContextMode(marker.auditorContext);
45
+ if (contextMode === null) {
46
+ checks.push(check('throughline_context', 'fail', 'invalid_context_configuration'));
47
+ } else if (contextMode === 'disabled') {
48
+ checks.push(check('throughline_context', 'skipped', 'context_disabled'));
49
+ } else {
50
+ const context = await inspectAuditorContextFn({ projectRoot });
51
+ checks.push(context?.ok === true && context?.mode === 'throughline'
52
+ ? check('throughline_context', 'pass')
53
+ : check('throughline_context', 'unverified', 'context_provider_unavailable'));
54
+ }
55
+
56
+ const catalogs = {};
57
+ for (const host of ['claude', 'codex']) {
58
+ const result = await inspectCatalog(localDbPath(projectRoot, host));
59
+ catalogs[host] = result.publicStatus;
60
+ checks.push(check(`${host}_catalog`, result.checkStatus, result.reasonCode));
61
+ }
62
+ checks.push(Object.values(catalogs).includes('available')
63
+ ? check('audit_catalog_readiness', 'pass')
64
+ : Object.values(catalogs).includes('invalid')
65
+ ? check('audit_catalog_readiness', 'fail', 'catalog_invalid_schema')
66
+ : check('audit_catalog_readiness', 'unverified', 'no_host_catalog'));
67
+
68
+ let codexReadiness = 'unverified';
69
+ try {
70
+ const diagnostics = await codexHookDiagnosticsFn({ projectRoot });
71
+ if (SAFE_CODEX_READINESS.has(diagnostics?.readiness)) codexReadiness = diagnostics.readiness;
72
+ } catch {
73
+ // A safe snapshot still reports that the inspector could not establish readiness.
74
+ }
75
+ checks.push(codexReadiness === 'configured-unverified'
76
+ ? check('codex_hooks', 'unverified', 'trust_not_machine_verifiable')
77
+ : codexReadiness === 'not-installed'
78
+ ? check('codex_hooks', 'skipped', 'not_installed')
79
+ : codexReadiness === 'misconfigured'
80
+ ? check('codex_hooks', 'fail', 'misconfigured')
81
+ : check('codex_hooks', 'unverified', 'diagnostics_unavailable'));
82
+
83
+ return snapshot({
84
+ overallStatus: overallStatus(checks),
85
+ markerSchemaVersion: markerVersion,
86
+ throughlineContext: contextMode ?? 'unverified',
87
+ catalogs,
88
+ codexHookReadiness: codexReadiness,
89
+ runtimeErrorStore,
90
+ checks,
91
+ });
92
+ }
93
+
94
+ function inactiveSnapshot(runtimeErrorStore) {
95
+ return snapshot({
96
+ overallStatus: 'not_applicable',
97
+ runtimeErrorStore,
98
+ checks: [check('project_activation', 'skipped', 'project_not_activated')],
99
+ });
100
+ }
101
+
102
+ function snapshot({
103
+ overallStatus,
104
+ markerSchemaVersion = null,
105
+ throughlineContext = 'unverified',
106
+ catalogs = { claude: 'not_applicable', codex: 'not_applicable' },
107
+ codexHookReadiness = 'not_applicable',
108
+ runtimeErrorStore,
109
+ checks,
110
+ }) {
111
+ return {
112
+ schema_version: '1.0',
113
+ product: 'spotter',
114
+ version,
115
+ overall_status: overallStatus,
116
+ marker_schema_version: markerSchemaVersion,
117
+ throughline_context: throughlineContext,
118
+ catalogs,
119
+ codex_hook_readiness: codexHookReadiness,
120
+ runtime_error_store: runtimeErrorStore,
121
+ checks,
122
+ };
123
+ }
124
+
125
+ async function readMarker(path) {
126
+ let raw;
127
+ try {
128
+ raw = await readFile(path, 'utf8');
129
+ } catch (error) {
130
+ return error?.code === 'ENOENT'
131
+ ? { status: 'missing' }
132
+ : { status: 'unverified', reasonCode: 'marker_unreadable' };
133
+ }
134
+ try {
135
+ const value = JSON.parse(raw);
136
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
137
+ return { status: 'unverified', reasonCode: 'marker_invalid_shape' };
138
+ }
139
+ return { status: 'valid', value };
140
+ } catch {
141
+ return { status: 'unverified', reasonCode: 'marker_invalid_json' };
142
+ }
143
+ }
144
+
145
+ async function inspectCatalog(path) {
146
+ try {
147
+ await access(path);
148
+ } catch (error) {
149
+ return error?.code === 'ENOENT'
150
+ ? { publicStatus: 'missing', checkStatus: 'skipped', reasonCode: 'catalog_missing' }
151
+ : { publicStatus: 'unverified', checkStatus: 'unverified', reasonCode: 'catalog_unreadable' };
152
+ }
153
+ try {
154
+ await loadDb(path);
155
+ return { publicStatus: 'available', checkStatus: 'pass' };
156
+ } catch {
157
+ return { publicStatus: 'invalid', checkStatus: 'fail', reasonCode: 'catalog_invalid_schema' };
158
+ }
159
+ }
160
+
161
+ function safeContextMode(config) {
162
+ if (config === undefined) return 'disabled';
163
+ return config && typeof config === 'object' && SAFE_CONTEXT_MODES.has(config.mode)
164
+ ? config.mode
165
+ : null;
166
+ }
167
+
168
+ function check(checkId, status, reasonCode) {
169
+ return reasonCode
170
+ ? { check_id: checkId, status, reason_code: reasonCode }
171
+ : { check_id: checkId, status };
172
+ }
173
+
174
+ function overallStatus(checks) {
175
+ if (checks.some((entry) => entry.status === 'fail')) return 'fail';
176
+ if (checks.some((entry) => entry.status === 'unverified')) return 'unverified';
177
+ return 'pass';
178
+ }
@@ -0,0 +1,51 @@
1
+ import { hasRuntimeErrorReceipt, observeRuntimeError } from './runtime-error-store.mjs';
2
+
3
+ async function main() {
4
+ if (process.env.SPOTTER_RUNTIME_ERROR_WORKER !== '1' || process.argv.length !== 5) process.exit(2);
5
+ const action = process.argv[2];
6
+ const value = process.argv[3];
7
+ if (action !== 'observe' && action !== 'receipt') process.exit(2);
8
+ let options;
9
+ try {
10
+ const encoded = process.argv[4];
11
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length > 16_384) process.exit(2);
12
+ options = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
13
+ } catch {
14
+ process.exit(2);
15
+ }
16
+ const allowed = action === 'observe'
17
+ ? new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'beforeOpenDelayMs', 'observationId'])
18
+ : new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']);
19
+ const required = new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId']);
20
+ if (!options || typeof options !== 'object' || Array.isArray(options)
21
+ || Object.keys(options).some((key) => !allowed.has(key))
22
+ || [...required].some((key) => !Object.hasOwn(options, key))
23
+ || (options.configPath !== null && (typeof options.configPath !== 'string' || options.configPath.length > 4_096))
24
+ || typeof options.storePath !== 'string' || options.storePath.length < 1 || options.storePath.length > 4_096
25
+ || typeof options.productVersion !== 'string' || options.productVersion.length < 1 || options.productVersion.length > 64
26
+ || typeof options.platform !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(options.platform)
27
+ || typeof options.arch !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(options.arch)
28
+ || typeof options.observationId !== 'string' || !/^[a-f0-9]{32}$/.test(options.observationId)
29
+ || (action === 'receipt' && (typeof options.expectedFingerprint !== 'string'
30
+ || !/^[a-f0-9]{64}$/.test(options.expectedFingerprint)
31
+ || !Number.isSafeInteger(options.waitMs) || options.waitMs < 10 || options.waitMs > 10_000))
32
+ || ('beforeOpenDelayMs' in options
33
+ && (!Number.isSafeInteger(options.beforeOpenDelayMs) || options.beforeOpenDelayMs < 0 || options.beforeOpenDelayMs > 10_000))) process.exit(2);
34
+ try {
35
+ if (action === 'receipt') {
36
+ if (value !== options.observationId) process.exit(2);
37
+ const deadline = Date.now() + options.waitMs;
38
+ do {
39
+ if (await hasRuntimeErrorReceipt(value, options.expectedFingerprint, options)) process.exit(0);
40
+ await new Promise((resolve) => setTimeout(resolve, 25));
41
+ } while (Date.now() < deadline);
42
+ process.exit(11);
43
+ }
44
+ const result = await observeRuntimeError(value, options);
45
+ process.exit(result.collected ? 0 : 10);
46
+ } catch {
47
+ process.exit(1);
48
+ }
49
+ }
50
+
51
+ main();