claude-spotter 1.9.3 → 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,17 @@
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
+
6
17
  ## 1.9.3 — 2026-10-07
7
18
 
8
19
  - 監査backendへ届かない・応じてもらえない失敗(network、timeout、認証、利用上限、5xx)を、1回ごとに
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.9.3",
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,6 +28,7 @@ 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';
@@ -228,6 +229,7 @@ export async function runCodexUserPromptSubmitHook({
228
229
  status: 'error',
229
230
  backend: err?.backend ?? null,
230
231
  code: failure.code,
232
+ ...failureEventFields(err),
231
233
  ...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
232
234
  legacyPendingDiagnostic: legacyPending.diagnostic,
233
235
  durationMs: Date.now() - startedAt,
@@ -339,6 +341,7 @@ export async function runCodexStopHook({
339
341
  hook: 'Stop',
340
342
  status: 'error',
341
343
  code: failure.code,
344
+ ...failureEventFields(err),
342
345
  reason: 'tool_usage_observation',
343
346
  usedToolCount: 0,
344
347
  durationMs: Date.now() - startedAt,
@@ -395,6 +398,7 @@ export async function runCodexStopHook({
395
398
  status: 'error',
396
399
  backend: err?.backend ?? null,
397
400
  code: failure.code,
401
+ ...failureEventFields(err),
398
402
  ...compactCodexModelSelectionForEvent(err?.diagnostics?.modelSelection),
399
403
  usedToolCount: usedTools.length,
400
404
  ...toolUsageEvent,
@@ -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;
@@ -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');
@@ -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}`);