claude-spotter 1.9.2 → 1.9.4

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
@@ -3,6 +3,32 @@
3
3
  各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。
4
4
  現行runtime契約は[`docs/00_overview.md`](https://github.com/kitepon/Spotter/blob/main/docs/00_overview.md)から辿る。
5
5
 
6
+ ## 1.9.4 — 2026-10-09
7
+
8
+ - Windowsで、監査の制限時間と同時にCodexが自分で終わった時、`taskkill`がrootを見つけられず(exit 128)、
9
+ Spotterが「process treeの終了を確認できない」(`E_CODEX_CLI_TERMINATION`)として扱っていた。この失敗は
10
+ 汎用の失敗として`SPOTTER.AUDITOR.UNAVAILABLE`へ即時登録される。child自身のclose(終了と全stdio pipeのEOF)を
11
+ 確認できた時だけ終了済みとし、監査はtimeoutのまま扱う。closeを確認できない時と、128以外の`taskkill`失敗は
12
+ 従来どおり`E_CODEX_CLI_TERMINATION`にする。
13
+ - CodexとGrokの失敗hook eventへ、失敗した段の内部code(`internalCode`)と、あればその原因のcode・終了status
14
+ (`causeCode`、`causeExitCode`)を残す。これまでは表示用にまとめた`code`だけが残り、1回の失敗の種類を
15
+ logから読み戻せなかった。記録するのは固定の識別子と終了statusだけで、hookの出力とstderrは変えていない。
16
+
17
+ ## 1.9.3 — 2026-10-07
18
+
19
+ - 監査backendへ届かない・応じてもらえない失敗(network、timeout、認証、利用上限、5xx)を、1回ごとに
20
+ runtime errorへ登録しない。Spotterは固定の通知を出し、親のturnと入力を保ち、次の監査で再試行する。
21
+ 1回の失敗はhook eventとdaemon logに残り、backendごとの連続失敗として`auditor-availability-v1.json`に持つ。
22
+ 完了した監査が挟まらないまま30分以上続いた時だけ、`SPOTTER.AUDITOR.UNRECOVERED`(`high`)を
23
+ 障害1件につき1回登録する。原因は決めつけない。それ以外の監査失敗は従来どおり毎回
24
+ `SPOTTER.AUDITOR.UNAVAILABLE`(`warn`)へ登録する。BugHubへ送る項目は変えていない。
25
+ - 重大度を、止まる範囲と復帰の有無に合わせた。sessionまたはbackendの監査が止まり復帰が観測されない物は
26
+ `high`、影響が監査1回・hookの要求1回までの物は`warn`。入力・会話・結果の喪失と二重実行はどの種類にも無い。
27
+ - hookとの接続1本の障害を、daemonが待ち受けられない失敗(`SPOTTER.DAEMON.TRANSPORT`、`high`)から分け、
28
+ `SPOTTER.DAEMON.CONNECTION`(`warn`)として登録する。daemonは他の接続を処理し続ける。
29
+ - `SPOTTER.AUDITOR.UNRECOVERED`または`SPOTTER.DAEMON.CONNECTION`の記録を持つstoreは、1.9.2以前のSpotterでは
30
+ 読めない(未知の記録として拒否する)。
31
+
6
32
  ## 1.9.2 — 2026-10-06
7
33
 
8
34
  - daemonが起動直後にsessionのcwdを離れ、`~/.spotter`へ移る。hostが`SessionEnd`を実行せずに終わると、
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,10 +28,14 @@ import {
28
28
  } from '../hooks/parent-output-projector.mjs';
29
29
  import {
30
30
  appendHookEvent,
31
+ failureEventFields,
31
32
  hookEventsPath,
32
33
  summarizeHookEvents,
33
34
  } from '../core/hook-event-log.mjs';
34
- import { observeRuntimeErrorIsolatedSafe } from '../core/runtime-error-store.mjs';
35
+ import {
36
+ observeAuditorAvailabilityIsolatedSafe, observeRuntimeErrorIsolatedSafe,
37
+ } from '../core/runtime-error-store.mjs';
38
+ import { reportAuditorFailure, reportAuditorSuccess } from '../core/auditor-outcome.mjs';
35
39
  import { createEvaluationStore } from '../core/evaluation-store.mjs';
36
40
  import { loadEvaluationContext } from '../core/evaluation-context.mjs';
37
41
  import {
@@ -87,11 +91,17 @@ export async function runCodexHookCommand({ argv = process.argv.slice(2) } = {})
87
91
  return;
88
92
  }
89
93
  if (sub === 'user-prompt-submit') {
90
- await runCodexUserPromptSubmitHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
94
+ await runCodexUserPromptSubmitHook({
95
+ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
96
+ auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
97
+ });
91
98
  return;
92
99
  }
93
100
  if (sub === 'stop') {
94
- await runCodexStopHook({ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe });
101
+ await runCodexStopHook({
102
+ runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
103
+ auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
104
+ });
95
105
  return;
96
106
  }
97
107
  process.stderr.write(`unknown codex-hook subcommand: ${sub}\n${CODEX_HOOK_USAGE}`);
@@ -130,6 +140,7 @@ export async function runCodexUserPromptSubmitHook({
130
140
  writeOutput = (text) => process.stdout.write(text),
131
141
  writeError = (text) => process.stderr.write(text),
132
142
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
143
+ auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
133
144
  createEvaluationStoreFn = createEvaluationStore,
134
145
  loadEvaluationContextFn = loadEvaluationContext,
135
146
  randomUUIDFn = randomUUID,
@@ -206,7 +217,9 @@ export async function runCodexUserPromptSubmitHook({
206
217
  });
207
218
  } catch (err) {
208
219
  await recordEvaluation({ auditStatus: 'error', backend: err?.backend ?? null, model: err?.diagnostics?.modelSelection?.effectiveModel ?? null });
209
- if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
220
+ if (enteredAuditorBoundary) {
221
+ await reportAuditorFailure(err, { runtimeErrorObserver, auditorAvailabilityObserver, backend: backend?.name });
222
+ }
210
223
  const failure = projectBackendFailure(err?.code);
211
224
  safeWriteError(writeError, failure.stderr);
212
225
  await recordCodexHookEventSafe(recordHookEventFn, {
@@ -216,6 +229,7 @@ export async function runCodexUserPromptSubmitHook({
216
229
  status: 'error',
217
230
  backend: err?.backend ?? null,
218
231
  code: failure.code,
232
+ ...failureEventFields(err),
219
233
  ...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
220
234
  legacyPendingDiagnostic: legacyPending.diagnostic,
221
235
  durationMs: Date.now() - startedAt,
@@ -224,6 +238,7 @@ export async function runCodexUserPromptSubmitHook({
224
238
  writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
225
239
  return;
226
240
  }
241
+ await reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend: backend.name });
227
242
  await recordCodexHookEventSafe(recordHookEventFn, {
228
243
  projectRoot,
229
244
  event: {
@@ -267,6 +282,7 @@ export async function runCodexStopHook({
267
282
  writeOutput = (text) => process.stdout.write(text),
268
283
  writeError = (text) => process.stderr.write(text),
269
284
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
285
+ auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
270
286
  createEvaluationStoreFn = createEvaluationStore,
271
287
  codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'),
272
288
  now = () => Date.now(),
@@ -325,6 +341,7 @@ export async function runCodexStopHook({
325
341
  hook: 'Stop',
326
342
  status: 'error',
327
343
  code: failure.code,
344
+ ...failureEventFields(err),
328
345
  reason: 'tool_usage_observation',
329
346
  usedToolCount: 0,
330
347
  durationMs: Date.now() - startedAt,
@@ -368,7 +385,9 @@ export async function runCodexStopHook({
368
385
  backend = createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBackendFn });
369
386
  judgment = await backend.judge({ stage: 'turn_end', finalResponse, usedTools });
370
387
  } catch (err) {
371
- if (enteredAuditorBoundary) await observeRuntimeFailure(runtimeErrorObserver, 'auditor_unavailable');
388
+ if (enteredAuditorBoundary) {
389
+ await reportAuditorFailure(err, { runtimeErrorObserver, auditorAvailabilityObserver, backend: backend?.name });
390
+ }
372
391
  const failure = projectBackendFailure(err?.code);
373
392
  reportError(failure.stderr);
374
393
  writeCodexSystemMessage({ systemMessage: failure.systemMessage, writeOutput });
@@ -379,6 +398,7 @@ export async function runCodexStopHook({
379
398
  status: 'error',
380
399
  backend: err?.backend ?? null,
381
400
  code: failure.code,
401
+ ...failureEventFields(err),
382
402
  ...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
383
403
  usedToolCount: usedTools.length,
384
404
  ...toolUsageEvent,
@@ -387,6 +407,7 @@ export async function runCodexStopHook({
387
407
  }, reportError);
388
408
  return;
389
409
  }
410
+ await reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend: backend.name });
390
411
  if (judgment.pass === true) {
391
412
  await recordCodexHookEventSafe(recordHookEventFn, {
392
413
  projectRoot,
@@ -701,14 +722,6 @@ function createCodexHookAuditorBackend({ catalog, projectRoot, createAuditorBack
701
722
  });
702
723
  }
703
724
 
704
- async function observeRuntimeFailure(observer, kind) {
705
- try {
706
- await observer(kind);
707
- } catch {
708
- // Runtime error telemetry must not alter hook output or exit behavior.
709
- }
710
- }
711
-
712
725
  function resolveCodexHookAuditorBackend({ env }) {
713
726
  return selectAuditorBackend({ hostAgent: 'codex', env }).backend;
714
727
  }
@@ -5,7 +5,9 @@ import { homedir } from 'node:os';
5
5
  import { join, resolve } 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
+ import {
9
+ observeAuditorAvailabilityIsolatedSafe, observeRuntimeErrorIsolatedSafe,
10
+ } from '../core/runtime-error-store.mjs';
9
11
 
10
12
  function parseArgs(argv) {
11
13
  const out = { sessionId: null, projectRoot: null };
@@ -94,6 +96,7 @@ export async function runDaemonStart({ argv }) {
94
96
  projectRoot,
95
97
  logFn: log,
96
98
  runtimeErrorObserver: observeRuntimeErrorIsolatedSafe,
99
+ auditorAvailabilityObserver: observeAuditorAvailabilityIsolatedSafe,
97
100
  });
98
101
  } catch (err) {
99
102
  if (err instanceof DaemonAlreadyRunningError) {
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { createAuditorBackend } from '../core/auditor-backend.mjs';
8
8
  import { createEvaluationStore } from '../core/evaluation-store.mjs';
9
- import { appendHookEventSafe } from '../core/hook-event-log.mjs';
9
+ import { appendHookEventSafe, failureEventFields } from '../core/hook-event-log.mjs';
10
10
  import { projectBackendFailure, projectToolIds } from '../hooks/parent-output-projector.mjs';
11
11
  import { findSpotterMarker, isChildCall, readStdinJson, requireString } from '../hooks/lib.mjs';
12
12
  import { readLocal, refresh } from '../tool-db/refresh.mjs';
@@ -154,7 +154,7 @@ export async function runGrokHook({
154
154
  const failure = projectBackendFailure(error?.code);
155
155
  writeError(failure.stderr);
156
156
  await record(projectRoot, expected, {
157
- status: 'degraded', code: failure.code, durationMs: Date.now() - startedAt,
157
+ status: 'degraded', code: failure.code, ...failureEventFields(error), durationMs: Date.now() - startedAt,
158
158
  }, recordFn);
159
159
  if (prompt !== null) await recordGrokEvaluation({ createEvaluationStoreFn, projectRoot, input, prompt, status: 'error', writeError });
160
160
  return;
@@ -0,0 +1,56 @@
1
+ // Decides how one auditor outcome reaches the runtime error store. A single failure to reach
2
+ // or be served by the external backend is a handled, self-retrying condition: Spotter shows the
3
+ // fixed notice, keeps the parent turn and the user's input, and audits again on the next turn.
4
+ // It stays in the hook-event and daemon logs. It is registered only when it does not recover.
5
+
6
+ export const AUDITOR_AVAILABILITY_BACKENDS = new Set(['jev', 'haiku', 'codex-cli', 'unknown']);
7
+
8
+ // The code picks only when to register. It does not say whether the product or the
9
+ // environment is at fault: a timeout or a rejected login can be either.
10
+ const AUDITOR_BACKEND_ACCESS_CODES = new Set([
11
+ 'E_JEV_NETWORK', 'E_JEV_TIMEOUT', 'E_JEV_AUTH', 'E_JEV_USAGE_LIMIT',
12
+ 'E_CODEX_CLI_TIMEOUT', 'E_CODEX_CLI_AUTH', 'E_CODEX_CLI_USAGE_LIMIT',
13
+ 'E_HAIKU_TIMEOUT',
14
+ ]);
15
+
16
+ // 'immediate' keeps the registration on every occurrence. 'on_unrecovered' is registered
17
+ // only when the failure streak outlives the recovery window without a successful audit.
18
+ export function auditorFailureLane(error) {
19
+ const code = error?.code;
20
+ if (AUDITOR_BACKEND_ACCESS_CODES.has(code)) return 'on_unrecovered';
21
+ const status = error?.diagnostics?.status;
22
+ if (code === 'E_JEV_HTTP' && Number.isSafeInteger(status) && status >= 500) return 'on_unrecovered';
23
+ return 'immediate';
24
+ }
25
+
26
+ export function auditorAvailabilityBackend(value) {
27
+ return AUDITOR_AVAILABILITY_BACKENDS.has(value) ? value : 'unknown';
28
+ }
29
+
30
+ export async function reportAuditorFailure(error, {
31
+ runtimeErrorObserver, auditorAvailabilityObserver, backend,
32
+ }) {
33
+ try {
34
+ if (auditorFailureLane(error) === 'immediate') {
35
+ await runtimeErrorObserver('auditor_unavailable');
36
+ return;
37
+ }
38
+ await auditorAvailabilityObserver({
39
+ outcome: 'failure', backend: auditorAvailabilityBackend(error?.backend ?? backend),
40
+ });
41
+ } catch {
42
+ // Runtime error telemetry must not alter hook output, exit behavior or daemon state.
43
+ }
44
+ }
45
+
46
+ // A judgment produced without contacting the backend proves nothing about its availability.
47
+ export async function reportAuditorSuccess(judgment, { auditorAvailabilityObserver, backend }) {
48
+ if (judgment?.meta?.mode === 'empty_catalog') return;
49
+ try {
50
+ await auditorAvailabilityObserver({
51
+ outcome: 'success', backend: auditorAvailabilityBackend(judgment?.meta?.backend ?? backend),
52
+ });
53
+ } catch {
54
+ // Same telemetry safety boundary as reportAuditorFailure.
55
+ }
56
+ }
@@ -24,7 +24,10 @@
24
24
  // backendDurationMs?: number | null,
25
25
  // usedToolCount?: number,
26
26
  // legacyPendingDiagnostic?: string | null,
27
- // toolName?: string | null
27
+ // toolName?: string | null,
28
+ // internalCode?: string | null, // failing step's own code; `code` is the projected one
29
+ // causeCode?: string,
30
+ // causeExitCode?: number
28
31
  // }
29
32
 
30
33
  import { appendFile, mkdir, readFile } from 'node:fs/promises';
@@ -34,6 +37,23 @@ const HOOK_EVENTS_FILE = 'hook-events.jsonl';
34
37
  export const HOOK_EVENT_SCHEMA = 'spotter.hook_event.v1';
35
38
  export const HOOK_EVENTS_SUMMARY_SCHEMA = 'spotter.hook_events_summary.v1';
36
39
 
40
+ const EVENT_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
41
+
42
+ function eventCode(value) {
43
+ return typeof value === 'string' && EVENT_CODE_PATTERN.test(value) ? value : null;
44
+ }
45
+
46
+ // `code` holds the projected, user-facing code, which folds many failures into one generic value.
47
+ // These fields keep the failing step's own code so a single failure can be read back from the log.
48
+ // Only fixed identifiers and an exit status are taken; messages and provider output never are.
49
+ export function failureEventFields(error) {
50
+ const fields = { internalCode: eventCode(error?.code) };
51
+ const causeCode = eventCode(error?.cause?.code);
52
+ if (causeCode !== null) fields.causeCode = causeCode;
53
+ if (Number.isInteger(error?.cause?.exitCode)) fields.causeExitCode = error.cause.exitCode;
54
+ return fields;
55
+ }
56
+
37
57
  export function hookEventsPath(projectRoot) {
38
58
  if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
39
59
  throw new TypeError('hookEventsPath: projectRoot must be a non-empty string');
@@ -1,10 +1,10 @@
1
- import { hasRuntimeErrorReceipt, observeRuntimeError } from './runtime-error-store.mjs';
1
+ import { hasRuntimeErrorReceipt, observeAuditorAvailability, observeRuntimeError } from './runtime-error-store.mjs';
2
2
 
3
3
  async function main() {
4
4
  if (process.env.SPOTTER_RUNTIME_ERROR_WORKER !== '1' || process.argv.length !== 5) process.exit(2);
5
5
  const action = process.argv[2];
6
6
  const value = process.argv[3];
7
- if (action !== 'observe' && action !== 'receipt') process.exit(2);
7
+ if (action !== 'observe' && action !== 'receipt' && action !== 'availability') process.exit(2);
8
8
  let options;
9
9
  try {
10
10
  const encoded = process.argv[4];
@@ -15,8 +15,11 @@ async function main() {
15
15
  }
16
16
  const allowed = action === 'observe'
17
17
  ? new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'beforeOpenDelayMs', 'observationId'])
18
- : new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']);
19
- const required = new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId']);
18
+ : action === 'availability'
19
+ ? new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch'])
20
+ : new Set(['configPath', 'productConfigPath', 'storePath', 'productVersion', 'platform', 'arch', 'observationId', 'expectedFingerprint', 'waitMs']);
21
+ const required = new Set(['configPath', 'storePath', 'productVersion', 'platform', 'arch']);
22
+ if (action !== 'availability') required.add('observationId');
20
23
  if (!options || typeof options !== 'object' || Array.isArray(options)
21
24
  || Object.keys(options).some((key) => !allowed.has(key))
22
25
  || [...required].some((key) => !Object.hasOwn(options, key))
@@ -26,7 +29,7 @@ async function main() {
26
29
  || typeof options.productVersion !== 'string' || options.productVersion.length < 1 || options.productVersion.length > 64
27
30
  || typeof options.platform !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(options.platform)
28
31
  || typeof options.arch !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(options.arch)
29
- || typeof options.observationId !== 'string' || !/^[a-f0-9]{32}$/.test(options.observationId)
32
+ || (action !== 'availability' && (typeof options.observationId !== 'string' || !/^[a-f0-9]{32}$/.test(options.observationId)))
30
33
  || (action === 'receipt' && (typeof options.expectedFingerprint !== 'string'
31
34
  || !/^[a-f0-9]{64}$/.test(options.expectedFingerprint)
32
35
  || !Number.isSafeInteger(options.waitMs) || options.waitMs < 10 || options.waitMs > 10_000))
@@ -42,6 +45,14 @@ async function main() {
42
45
  } while (Date.now() < deadline);
43
46
  process.exit(11);
44
47
  }
48
+ if (action === 'availability') {
49
+ const separator = value.indexOf(':');
50
+ if (separator < 1) process.exit(2);
51
+ const result = await observeAuditorAvailability({
52
+ outcome: value.slice(0, separator), backend: value.slice(separator + 1),
53
+ }, options);
54
+ process.exit(result.collected ? 0 : 10);
55
+ }
45
56
  const result = await observeRuntimeError(value, options);
46
57
  process.exit(result.collected ? 0 : 10);
47
58
  } catch {
@@ -16,33 +16,68 @@ import { DatabaseSync } from 'node:sqlite';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { version } from '../version.mjs';
18
18
  import { WINDOWS_POWERSHELL_COMMAND } from '../platform/spawn.mjs';
19
+ import { AUDITOR_AVAILABILITY_BACKENDS } from './auditor-outcome.mjs';
19
20
  import { readRuntimeReportConfig } from './runtime-report-config.mjs';
20
21
 
21
22
  export const RUNTIME_ERROR_STORE_SCHEMA = 'spotter.runtime_errors.v1';
22
23
  export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0';
23
24
  export const RUNTIME_ERROR_STORE_FAILURE_DIAGNOSTIC = 'spotter-runtime-errors: local aggregate store unavailable\n';
24
25
 
26
+ // Severity follows what stops, what is lost and whether it comes back. No kind loses the user's
27
+ // input, conversation or results, and none runs anything twice, so none is fatal.
28
+ // - high: audits are stopped across a whole session or backend and no recovery is observed.
29
+ // - warn: at most one audit or one hook request is affected and the next one runs normally.
25
30
  export const RUNTIME_ERROR_DEFINITIONS = deepFreeze({
31
+ // The daemon could not listen, or its listening server failed. A daemon that cannot listen
32
+ // exits: every audit of that Claude session is missing from the first prompt, and each
33
+ // UserPromptSubmit retries the start and waits up to 3 s for it. It comes back only when the
34
+ // cause on the terminal is gone. For an error after listening the daemon keeps running and
35
+ // the extent is not determined here.
26
36
  daemon_transport: {
27
37
  component: 'daemon_transport',
28
38
  errorCode: 'SPOTTER.DAEMON.TRANSPORT',
29
39
  messageTemplate: 'Spotter daemon transport failed',
30
40
  severity: 'high',
31
41
  },
42
+ // A fault on one live hook connection. The daemon keeps serving and the next request opens
43
+ // a new connection. A hook that goes away before the reply does not raise it.
44
+ daemon_connection: {
45
+ component: 'daemon_transport',
46
+ errorCode: 'SPOTTER.DAEMON.CONNECTION',
47
+ messageTemplate: 'Spotter daemon connection to a hook failed; at most one hook request was affected',
48
+ severity: 'warn',
49
+ },
50
+ // The PID file could not be written, so the daemon closes its listener and exits. Same
51
+ // effect on the session as a daemon that cannot listen.
32
52
  daemon_persistence: {
33
53
  component: 'daemon_persistence',
34
54
  errorCode: 'SPOTTER.DAEMON.PERSISTENCE',
35
55
  messageTemplate: 'Spotter daemon state persistence failed',
36
56
  severity: 'high',
37
57
  },
58
+ // One audit failed for a reason other than backend access. The next audit runs normally.
38
59
  auditor_unavailable: {
39
60
  component: 'auditor',
40
61
  errorCode: 'SPOTTER.AUDITOR.UNAVAILABLE',
41
62
  messageTemplate: 'Spotter auditor backend was unavailable',
42
63
  severity: 'warn',
43
64
  },
65
+ // Registered once per outage, only after backend access kept failing with no completed
66
+ // audit in between: every audit through that backend on this terminal has been missing for
67
+ // 30 minutes or more and no recovery is observed.
68
+ auditor_unrecovered: {
69
+ component: 'auditor',
70
+ errorCode: 'SPOTTER.AUDITOR.UNRECOVERED',
71
+ messageTemplate: 'Spotter auditor backend access kept failing for over 30 minutes with no successful audit; cause not determined',
72
+ severity: 'high',
73
+ },
44
74
  });
45
75
 
76
+ export const AUDITOR_AVAILABILITY_SCHEMA = 'spotter.auditor_availability.v1';
77
+ export const AUDITOR_UNRECOVERED_AFTER_MS = 30 * 60 * 1_000;
78
+ const AUDITOR_AVAILABILITY_FILE = 'auditor-availability-v1.json';
79
+ const AUDITOR_AVAILABILITY_OUTCOMES = new Set(['success', 'failure']);
80
+
46
81
  const CONFIG_TOP_KEYS = new Set(['schema_version', 'host', 'collection', 'reporting']);
47
82
  const HOST_KEYS = new Set(['id', 'profile']);
48
83
  const COLLECTION_KEYS = new Set(['enabled']);
@@ -151,56 +186,220 @@ export async function observeRuntimeError(input, options = {}) {
151
186
  const definition = RUNTIME_ERROR_DEFINITIONS[kind];
152
187
  const fingerprint = runtimeErrorFingerprint(definition);
153
188
  const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
154
- const result = await mutateStore(storePath, options, (store) => {
155
- const receipt = observationId
156
- ? store.receipts.find((candidate) => candidate.id === observationId)
157
- : null;
158
- if (receipt) {
159
- if (receipt.fingerprint !== fingerprint) {
160
- throw storeError('runtime error observation id conflicts with another fingerprint');
161
- }
162
- const existing = store.records.find((record) => record.fingerprint === fingerprint);
163
- return { collected: true, fingerprint, sequence: existing?.sequence ?? null, duplicate: true };
189
+ return mutateStore(storePath, options, (store) => (
190
+ applyObservation(store, definition, fingerprint, observationId, options)
191
+ ));
192
+ }
193
+
194
+ function applyObservation(store, definition, fingerprint, observationId, options) {
195
+ const receipt = observationId
196
+ ? store.receipts.find((candidate) => candidate.id === observationId)
197
+ : null;
198
+ if (receipt) {
199
+ if (receipt.fingerprint !== fingerprint) {
200
+ throw storeError('runtime error observation id conflicts with another fingerprint');
164
201
  }
165
- const timestamp = nowIso(options.now);
166
202
  const existing = store.records.find((record) => record.fingerprint === fingerprint);
167
- const sequence = store.next_sequence++;
168
- if (existing) {
169
- existing.product_version = validateProductVersion(options.productVersion ?? version);
170
- existing.occurrence_count += 1;
171
- existing.last_seen = timestamp;
172
- existing.status = 'open';
173
- existing.resolved_at = null;
174
- existing.reason_code = null;
175
- existing.sequence = sequence;
176
- } else {
177
- store.records.push({
178
- product: 'spotter',
179
- product_version: validateProductVersion(options.productVersion ?? version),
180
- component: definition.component,
181
- error_code: definition.errorCode,
182
- message_template: definition.messageTemplate,
183
- severity: definition.severity,
184
- fingerprint,
185
- occurrence_count: 1,
186
- first_seen: timestamp,
187
- last_seen: timestamp,
188
- state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
189
- os: validatePlatform(options.platform ?? currentPlatform()),
190
- arch: validateArch(options.arch ?? currentArch()),
191
- status: 'open',
192
- resolved_at: null,
193
- reason_code: null,
194
- sequence,
195
- });
203
+ return { collected: true, fingerprint, sequence: existing?.sequence ?? null, duplicate: true };
204
+ }
205
+ const timestamp = nowIso(options.now);
206
+ const existing = store.records.find((record) => record.fingerprint === fingerprint);
207
+ const sequence = store.next_sequence++;
208
+ if (existing) {
209
+ existing.product_version = validateProductVersion(options.productVersion ?? version);
210
+ existing.occurrence_count += 1;
211
+ existing.last_seen = timestamp;
212
+ existing.status = 'open';
213
+ existing.resolved_at = null;
214
+ existing.reason_code = null;
215
+ existing.sequence = sequence;
216
+ } else {
217
+ store.records.push({
218
+ product: 'spotter',
219
+ product_version: validateProductVersion(options.productVersion ?? version),
220
+ component: definition.component,
221
+ error_code: definition.errorCode,
222
+ message_template: definition.messageTemplate,
223
+ severity: definition.severity,
224
+ fingerprint,
225
+ occurrence_count: 1,
226
+ first_seen: timestamp,
227
+ last_seen: timestamp,
228
+ state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
229
+ os: validatePlatform(options.platform ?? currentPlatform()),
230
+ arch: validateArch(options.arch ?? currentArch()),
231
+ status: 'open',
232
+ resolved_at: null,
233
+ reason_code: null,
234
+ sequence,
235
+ });
236
+ }
237
+ if (observationId) {
238
+ store.receipts.push({ id: observationId, fingerprint });
239
+ if (store.receipts.length > MAX_RECEIPTS) store.receipts.splice(0, store.receipts.length - MAX_RECEIPTS);
240
+ }
241
+ return { collected: true, fingerprint, sequence };
242
+ }
243
+
244
+ export function auditorAvailabilityPath(storePath) {
245
+ return join(dirname(storePath), AUDITOR_AVAILABILITY_FILE);
246
+ }
247
+
248
+ export async function observeAuditorAvailability(input, options = {}) {
249
+ const { outcome, backend } = validateAvailabilityInput(input);
250
+ const collection = await readRuntimeCollectionMode(options);
251
+ if (!collection.enabled) return { collected: false, reason: collection.mode };
252
+ const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
253
+ const statePath = auditorAvailabilityPath(storePath);
254
+ return enqueue(storePath, async () => {
255
+ await ensurePrivateDirectory(dirname(storePath), options);
256
+ return withStoreLock(runtimeErrorLockPath(storePath), options, async () => {
257
+ const state = await readAvailabilityState(statePath, options);
258
+ const streak = state.streaks[backend];
259
+ if (outcome === 'success') {
260
+ if (!streak) return { collected: true, streak: 'none' };
261
+ delete state.streaks[backend];
262
+ await writeAvailabilityState(statePath, state);
263
+ return { collected: true, streak: 'cleared' };
264
+ }
265
+ const timestamp = nowIso(options.now);
266
+ // A clock that moved backwards restarts the streak instead of producing a negative age.
267
+ const current = streak && streak.first_failed_at <= timestamp
268
+ ? { ...streak, last_failed_at: timestamp }
269
+ : { first_failed_at: timestamp, last_failed_at: timestamp, registered: false };
270
+ let registered = false;
271
+ if (!current.registered
272
+ && Date.parse(timestamp) - Date.parse(current.first_failed_at) >= AUDITOR_UNRECOVERED_AFTER_MS) {
273
+ const definition = RUNTIME_ERROR_DEFINITIONS.auditor_unrecovered;
274
+ const fingerprint = runtimeErrorFingerprint(definition);
275
+ // The streak start identifies the outage, so a retry after a failed state write
276
+ // cannot count the same outage twice.
277
+ const observationId = createHash('sha256')
278
+ .update(`auditor_unrecovered\n${backend}\n${current.first_failed_at}`, 'utf8').digest('hex').slice(0, 32);
279
+ const store = await readStore(storePath, options);
280
+ applyObservation(store, definition, fingerprint, observationId, { ...options, now: () => timestamp });
281
+ validateStore(store);
282
+ const atomicWriteFn = options.atomicWriteFn ?? atomicWriteStore;
283
+ await atomicWriteFn(storePath, `${JSON.stringify(store)}\n`, options);
284
+ current.registered = true;
285
+ registered = true;
286
+ }
287
+ state.streaks[backend] = current;
288
+ await writeAvailabilityState(statePath, state);
289
+ return { collected: true, streak: current.registered ? 'registered' : 'open', registered };
290
+ });
291
+ });
292
+ }
293
+
294
+ // Success is the hot path of every audit. A worker is started only when this backend has a
295
+ // failure streak to end; the unlocked read is a hint and the worker re-reads under the lock.
296
+ export async function observeAuditorAvailabilityIsolatedSafe(input, options = {}) {
297
+ let parsed;
298
+ try {
299
+ parsed = validateAvailabilityInput(input);
300
+ } catch {
301
+ return emitFixedStoreFailure(options);
302
+ }
303
+ const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
304
+ if (parsed.outcome === 'success') {
305
+ let streaks;
306
+ try {
307
+ streaks = JSON.parse(await readFile(auditorAvailabilityPath(storePath), 'utf8'))?.streaks;
308
+ } catch (error) {
309
+ if (error?.code === 'ENOENT') return { collected: false, reason: 'no_failure_streak' };
310
+ return emitFixedStoreFailure(options);
196
311
  }
197
- if (observationId) {
198
- store.receipts.push({ id: observationId, fingerprint });
199
- if (store.receipts.length > MAX_RECEIPTS) store.receipts.splice(0, store.receipts.length - MAX_RECEIPTS);
312
+ if (!streaks || typeof streaks !== 'object') return emitFixedStoreFailure(options);
313
+ if (!Object.hasOwn(streaks, parsed.backend)) return { collected: false, reason: 'no_failure_streak' };
314
+ }
315
+ const platform = options.platform ?? process.platform;
316
+ const timeoutMs = options.timeoutMs ?? (platform === 'win32'
317
+ ? WINDOWS_DEFAULT_ISOLATED_TIMEOUT_MS
318
+ : DEFAULT_ISOLATED_TIMEOUT_MS);
319
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 10_000) {
320
+ return emitFixedStoreFailure(options);
321
+ }
322
+ const workerOptions = {
323
+ configPath: options.configPath ?? null,
324
+ productConfigPath: options.productConfigPath,
325
+ storePath,
326
+ productVersion: options.productVersion ?? version,
327
+ platform: options.platform ?? process.platform,
328
+ arch: options.arch ?? process.arch,
329
+ };
330
+ const encoded = Buffer.from(JSON.stringify(workerOptions), 'utf8').toString('base64url');
331
+ const observed = await runRuntimeWorker(
332
+ options.workerPath ?? RUNTIME_ERROR_WORKER,
333
+ ['availability', `${parsed.outcome}:${parsed.backend}`, encoded],
334
+ timeoutMs,
335
+ );
336
+ if (observed.kind === 'exit' && observed.code === 0) return { collected: true };
337
+ if (observed.kind === 'exit' && observed.code === 10) return { collected: false, reason: 'collection_disabled' };
338
+ return emitFixedStoreFailure(options);
339
+ }
340
+
341
+ function validateAvailabilityInput(input) {
342
+ assertExactObject(input, new Set(['outcome', 'backend']));
343
+ if (!AUDITOR_AVAILABILITY_OUTCOMES.has(input.outcome) || !AUDITOR_AVAILABILITY_BACKENDS.has(input.backend)) {
344
+ throw inputError('invalid auditor availability observation');
345
+ }
346
+ return { outcome: input.outcome, backend: input.backend };
347
+ }
348
+
349
+ async function readAvailabilityState(statePath, options) {
350
+ let raw;
351
+ try {
352
+ raw = decodeUtf8(await readPrivateFile(statePath, options, { enforceWindowsAcl: false }));
353
+ } catch (error) {
354
+ if (error?.code === 'ENOENT') return { schema: AUDITOR_AVAILABILITY_SCHEMA, streaks: {} };
355
+ throw error;
356
+ }
357
+ let state;
358
+ try {
359
+ state = JSON.parse(raw);
360
+ } catch {
361
+ throw storeError('auditor availability state is malformed');
362
+ }
363
+ if (!state || typeof state !== 'object' || Array.isArray(state)
364
+ || !hasOnlyKeys(state, new Set(['schema', 'streaks'])) || Object.keys(state).length !== 2
365
+ || state.schema !== AUDITOR_AVAILABILITY_SCHEMA
366
+ || !state.streaks || typeof state.streaks !== 'object' || Array.isArray(state.streaks)
367
+ || !hasOnlyKeys(state.streaks, AUDITOR_AVAILABILITY_BACKENDS)) {
368
+ throw storeError('auditor availability state schema mismatch');
369
+ }
370
+ const streakKeys = new Set(['first_failed_at', 'last_failed_at', 'registered']);
371
+ for (const streak of Object.values(state.streaks)) {
372
+ if (!streak || typeof streak !== 'object' || Array.isArray(streak)
373
+ || !hasOnlyKeys(streak, streakKeys) || Object.keys(streak).length !== streakKeys.size
374
+ || !validTimestamp(streak.first_failed_at) || !validTimestamp(streak.last_failed_at)
375
+ || streak.first_failed_at > streak.last_failed_at || typeof streak.registered !== 'boolean') {
376
+ throw storeError('auditor availability state schema mismatch');
200
377
  }
201
- return { collected: true, fingerprint, sequence };
202
- });
203
- return result;
378
+ }
379
+ return state;
380
+ }
381
+
382
+ // The directory is already owner-private (0700, or the owner-only inherited ACL on Windows),
383
+ // and the state holds only backend names and timestamps.
384
+ async function writeAvailabilityState(statePath, state) {
385
+ if (Object.keys(state.streaks).length === 0) {
386
+ await rm(statePath, { force: true });
387
+ return;
388
+ }
389
+ const temporary = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
390
+ let handle;
391
+ try {
392
+ handle = await open(temporary, 'wx', 0o600);
393
+ await handle.writeFile(`${JSON.stringify(state)}\n`, 'utf8');
394
+ await handle.sync();
395
+ await handle.close();
396
+ handle = null;
397
+ await rename(temporary, statePath);
398
+ } catch (error) {
399
+ await handle?.close().catch(() => {});
400
+ await rm(temporary, { force: true }).catch(() => {});
401
+ throw error;
402
+ }
204
403
  }
205
404
 
206
405
  export async function observeRuntimeErrorSafe(input, options = {}) {
@@ -35,6 +35,7 @@ import { readFile } from 'node:fs/promises';
35
35
  import { createServer, ensureRuntimeDir, removeStaleSocketFile, secureSocketFile, socketPath } from './transport.mjs';
36
36
  import { readLocal } from '../tool-db/refresh.mjs';
37
37
  import { legacyResultFromJudgment } from '../core/judgment.mjs';
38
+ import { reportAuditorFailure, reportAuditorSuccess } from '../core/auditor-outcome.mjs';
38
39
  import {
39
40
  createAuditorBackend,
40
41
  DEFAULT_HAIKU_AUDITOR_TIMEOUT_MS,
@@ -86,6 +87,7 @@ export async function startDaemon({
86
87
  auditorEnv = process.env,
87
88
  stopShortFinalMaxChars = resolveStopShortFinalMaxChars(process.env),
88
89
  runtimeErrorObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
90
+ auditorAvailabilityObserver = async () => ({ collected: false, reason: 'observer_not_configured' }),
89
91
  createAuditorBackendFn = createAuditorBackend,
90
92
  createServerFn = createServer,
91
93
  removeStaleSocketFileFn = removeStaleSocketFile,
@@ -180,14 +182,20 @@ export async function startDaemon({
180
182
  // Tests may pass haikuCallWindowMs: 0 to disable this guard.
181
183
  let lastAuditorCallAt = 0;
182
184
 
185
+ const auditorOutcomeObservers = {
186
+ runtimeErrorObserver, auditorAvailabilityObserver, backend: auditorBackend.name,
187
+ };
183
188
  const runAuditorJudgment = async (input) => {
184
189
  lastAuditorCallAt = Date.now();
190
+ let judgment;
185
191
  try {
186
- return await auditorBackend.judge(input);
192
+ judgment = await auditorBackend.judge(input);
187
193
  } catch (error) {
188
- await observeFailure('auditor_unavailable');
194
+ await reportAuditorFailure(error, auditorOutcomeObservers);
189
195
  throw error;
190
196
  }
197
+ await reportAuditorSuccess(judgment, auditorOutcomeObservers);
198
+ return judgment;
191
199
  };
192
200
 
193
201
  // v0.12.0: heartbeat. Reset on every envelope; if no event arrives within
@@ -405,9 +413,9 @@ export async function startDaemon({
405
413
  if (envelope?.event === 'tool_used' && envelope.payload?.evaluation_observed === true) {
406
414
  state.evaluationUsageIncomplete = true;
407
415
  }
408
- // Handler/auditor failures are owned above. A connection-level error has no
409
- // envelope and belongs to the transport boundary here.
410
- if (envelope === null) void observeFailure('daemon_transport');
416
+ // Handler/auditor failures are owned above. A connection-level error has no envelope.
417
+ // It affects that one connection; the daemon keeps serving the others.
418
+ if (envelope === null) void observeFailure('daemon_connection');
411
419
  };
412
420
 
413
421
  const { server, path } = createServerFn({ sessionId, handler, onError: onErrorFn });
package/src/index.mjs CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  summarizeDaemonLogText,
49
49
  summarizeDaemonLogs,
50
50
  } from './core/daemon-log-diagnostics.mjs';
51
+ export { auditorFailureLane } from './core/auditor-outcome.mjs';
51
52
  export {
52
53
  RUNTIME_ERROR_DEFINITIONS,
53
54
  RUNTIME_ERROR_STORE_SCHEMA,
@@ -55,6 +56,8 @@ export {
55
56
  compactRuntimeErrors,
56
57
  defaultFactoryReporterConfigPath,
57
58
  defaultRuntimeErrorStorePath,
59
+ observeAuditorAvailability,
60
+ observeAuditorAvailabilityIsolatedSafe,
58
61
  observeRuntimeError,
59
62
  observeRuntimeErrorIsolatedSafe,
60
63
  observeRuntimeErrorSafe,
@@ -134,12 +134,41 @@ export function npmShimEntryPath(source, shimDir) {
134
134
  return win32.join(shimDir, ...segments);
135
135
  }
136
136
 
137
+ // taskkill は root の pid が存在しない時に 128 で終わる。この時 taskkill は何も終了していない。
138
+ const TASKKILL_PROCESS_NOT_FOUND = 128;
139
+
140
+ // 'close' は process の終了と全 stdio pipe の EOF を意味する。root より長生きした子孫が
141
+ // pipe を持っている間は stream が閉じないので、ここは false になる。
142
+ function childHasClosed(child) {
143
+ if (!Number.isInteger(child.exitCode) && typeof child.signalCode !== 'string') return false;
144
+ return [child.stdin, child.stdout, child.stderr].every((stream) => !stream || stream.destroyed === true);
145
+ }
146
+
147
+ function waitForChildClose(child, graceMs) {
148
+ if (childHasClosed(child)) return Promise.resolve(true);
149
+ if (typeof child.once !== 'function' || typeof child.off !== 'function') return Promise.resolve(false);
150
+ return new Promise((resolve) => {
151
+ const onClose = () => {
152
+ clearTimeout(timer);
153
+ resolve(true);
154
+ };
155
+ const timer = setTimeout(() => {
156
+ child.off('close', onClose);
157
+ resolve(childHasClosed(child));
158
+ }, graceMs);
159
+ child.once('close', onClose);
160
+ });
161
+ }
162
+
137
163
  // cmd.exe shim のみ kill すると孫の CLI が残り得るため、Windows は process tree を
138
164
  // taskkill で終了する。taskkill 自体の起動・終了に失敗した時だけ direct kill へ戻す。
165
+ // taskkill が root を見つけられなかった時は、child 自身の close を確認できた場合だけ終了済みとする。
166
+ // timeout と同時に自分で終わった process を「終了未確認」にしないためで、close が無ければ従来どおり失敗する。
139
167
  export function terminateProcessTree(child, {
140
168
  platform = process.platform,
141
169
  spawnFn = spawn,
142
170
  timeoutMs = 5_000,
171
+ closeGraceMs = 500,
143
172
  } = {}) {
144
173
  if (!child || typeof child.kill !== 'function') return Promise.resolve();
145
174
  if (platform !== 'win32' || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
@@ -184,9 +213,20 @@ export function terminateProcessTree(child, {
184
213
  finish();
185
214
  return;
186
215
  }
187
- const error = new Error(`taskkill exited with code ${code}`);
188
- error.code = 'E_PROCESS_TREE_TERMINATION';
189
- finish(error);
216
+ const fail = () => {
217
+ const error = new Error(`taskkill exited with code ${code}`);
218
+ error.code = 'E_PROCESS_TREE_TERMINATION';
219
+ error.exitCode = code;
220
+ finish(error);
221
+ };
222
+ if (code !== TASKKILL_PROCESS_NOT_FOUND) {
223
+ fail();
224
+ return;
225
+ }
226
+ waitForChildClose(child, closeGraceMs).then((closed) => {
227
+ if (closed) finish();
228
+ else fail();
229
+ });
190
230
  });
191
231
  } catch (cause) {
192
232
  const error = new Error(`taskkill spawn failed: ${cause.message}`);