claude-spotter 1.4.21 → 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,12 +1,37 @@
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
+
15
+ ## 1.4.22 — 2026-07-13
16
+
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へ同期した。
19
+
3
20
  ## 1.4.21 — 2026-07-13
4
21
 
5
22
  ### Changed
6
23
 
7
24
  - **Throughline監査文脈をdefault-onへ変更。** `spotter install`がPATH上のThroughlineをabsolute pathへ解決できる場合、project markerへ既定設定する。marker v2の`origin:default|explicit`で旧既定disabledだけを移行し、明示OFFは再install後も維持する。POSIXはrealpath、Windows npm shimはabsolute `node.exe + throughline.mjs`を保存する。
8
25
  - **不在時は明示disabled。** Throughlineが見つからない場合は固定理由付きdisabledとし、current-only監査や別backendへfallbackしない。installerとdoctorが状態・OFF手順を表示する。
9
- - **実運用効果測定。** default-on7日以上・fresh 30件以上を集め、過検出・見逃し・stale率・latencyから維持、修正、default-off rollbackを判断する。L2本文は評価ログへ保存しない。
26
+ - **実運用効果測定。** default-onはowner確定済み。7日以上・fresh 30件以上の過検出・見逃し・stale率・latencyは精度改善にだけ使い、ON/OFFを再審査しない。L2本文は評価ログへ保存しない。
27
+
28
+ ### 検証・公開
29
+
30
+ 478 tests(476 pass / 2 skip)、macOS/Linux/Windows × Node 22.5/22.xのCI 6/6、packの秘密・
31
+ 開発者固有path scan、隔離tarball installを通過。公開commitは`5026ace`。npm `claude-spotter@1.4.21`、
32
+ tag / GitHub Release `v1.4.21`、registry由来global installを同期した。registry版の空projectへ
33
+ `spotter install -y`を実行し、marker v2がThroughlineのglobal実体を`origin:default`で設定し、
34
+ doctorがconnector availableを返すことを確認した。
10
35
 
11
36
  ## 1.4.20 — 2026-07-13
12
37
 
package/README.ja.md CHANGED
@@ -190,7 +190,7 @@ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.js
190
190
  --recent-turns 2 --body-cap 600
191
191
  ```
192
192
 
193
- 評価結論はN=2 / 600です。既定ONの実運用で7日・30 fresh resultを集め、維持・修正・rollbackを判断します。
193
+ 評価結論はN=2 / 600です。既定ONは確定済みで、7日・30 fresh resultの実運用測定は精度改善に使います。
194
194
 
195
195
  ## よく使うコマンド
196
196
 
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)
@@ -195,8 +195,8 @@ spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.js
195
195
  --recent-turns 2 --body-cap 600
196
196
  ```
197
197
 
198
- The evaluated setting is N=2 / 600. Default-on operation now collects the 7-day,
199
- 30-fresh-result sample used to decide whether to keep, adjust, or roll back the default.
198
+ The evaluated setting is N=2 / 600. Default-on is final; the 7-day,
199
+ 30-fresh-result sample is used only to identify precision improvements.
200
200
 
201
201
  ## Common commands
202
202
 
@@ -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.21",
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
+ }