claude-spotter 1.4.6 → 1.4.9

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,5 +1,169 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.9
4
+
5
+ **Codex hooks feature 名の現行 CLI 追従**。現行 Codex CLI の `codex features list` は
6
+ hook 機能を `hooks stable true` と表示するが、Spotter の `codex-hook diagnostics` は旧名
7
+ `codex_hooks` だけを見ていたため、hooks 登録済みでも `availability:"unavailable"` と誤判定していた。
8
+
9
+ ### 変更点
10
+
11
+ - **編集 [src/cli/codex-hook-cmd.mjs](src/cli/codex-hook-cmd.mjs)**:
12
+ `codexHookDiagnostics` が現行 `hooks` と旧 `codex_hooks` の両方を enabled evidence として扱う。
13
+ `installCodexHooks` は現行 CLI に合わせて `[features].hooks = true` を書く。既に旧
14
+ `codex_hooks = true` がある環境では削除せず、`hooks = true` を追加して現行 CLI で確実に有効化する。
15
+ - **編集 [test/codex-hook-cmd.test.mjs](test/codex-hook-cmd.test.mjs)**:
16
+ 現行 `hooks stable true` と旧 `codex_hooks stable true` の diagnostics 回帰テスト、
17
+ 旧 feature key が残る config への install 回帰テストを追加。
18
+ - **編集 docs**:
19
+ `open-issues.md` の Codex pending / hook event path 記述を v1.4.8 以降の
20
+ host-neutral `.spotter/pending/` / `.spotter/hook-events.jsonl` に追従。
21
+
22
+ ### ユーザー側で必要な手順
23
+
24
+ 1. `npm install -g claude-spotter@1.4.9`
25
+ 2. Codex hooks を使う環境では、各 Spotter install 済み project で `spotter install` を再実行
26
+ (`~/.codex/config.toml` に `[features].hooks = true` を確実に反映するため)
27
+
28
+ ### 検証
29
+
30
+ - `node --test` 322 tests / 321 pass / 1 skip 緑
31
+ - `spotter codex-hook diagnostics --project /home/kite/projects/Spotter` が
32
+ `availability:"available"` / evidence=`hooks stable true` を返すことを実機確認
33
+
34
+ ## 1.4.8
35
+
36
+ **Hook 挙動 parity (Codex → Claude) 移植**。Codex 側で確定していた 3 つの hook 挙動 — Stop
37
+ short-skip / Stop deferred delivery / hook event JSONL ログ — を Claude 側にも適用し、
38
+ 両 host で同じ思想で動くよう揃えた。`decision:"block"` は Claude hook から完全撤去された。
39
+
40
+ ### 変更点
41
+
42
+ - **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)** (Phase A):
43
+ `handleTurnEnd` 冒頭に short-final + 0 used_tools の skip 分岐を追加。最終応答が ≤120 chars
44
+ (code-point 単位) かつ used_tools 0 件のとき auditor を呼ばずに
45
+ `{pass:true, reason:"short_final_no_tools"}` を即返す。`SPOTTER_STOP_SHORT_FINAL_MAX_CHARS`
46
+ で閾値変更、`<= 0` で機能無効化。Codex 側 `shouldSkipShortCodexStop` と同じ判定軸。
47
+ pure helper `shouldSkipShortStop` / `resolveStopShortFinalMaxChars` を export。
48
+ - **編集 [src/hooks/stop.mjs](src/hooks/stop.mjs)** (Phase B):
49
+ `decision:"block"` を撤去。daemon が `pass:false` を返したら、
50
+ `<projectRoot>/.spotter/pending/<sessionId>.json` に指摘テキスト (formatTransparentBlockReason
51
+ の同じ wording) を append し、stdout は空のまま exit 0。次の UserPromptSubmit が drain して
52
+ `additionalContext` で配信する。`stop_hook_active:true` の早期 pass は維持。
53
+ - **編集 [src/hooks/user-prompt.mjs](src/hooks/user-prompt.mjs)** (Phase B):
54
+ 入口で `<projectRoot>/.spotter/pending/<sessionId>.json` を drain → `additionalContext` に統合。
55
+ daemon の `pass:false` 結果と pending drain は同じ `additionalContext` に合体。短プロンプト
56
+ 早期 return 経路でも drain は走るので pending が一時返答に詰まらない。
57
+ - **新規 [src/hooks/pending-context.mjs](src/hooks/pending-context.mjs)** (Phase B):
58
+ 共有 pending queue helper (`pendingPath` / `appendPendingContext` / `drainPendingContexts` /
59
+ `readPendingContexts`)。Claude / Codex 両 host から同じ実装を通る。pending file は
60
+ `<projectRoot>/.spotter/pending/<sanitized-id>.json`、JSON 配列形式、識別 dedupe。
61
+ - **編集 [src/cli/codex-hook-cmd.mjs](src/cli/codex-hook-cmd.mjs)** (Phase B + Phase D):
62
+ Codex 側 private `codexPendingPath` / `appendCodexPendingContext` / `drainCodexPendingContexts`
63
+ / `readCodexPendingContexts` を共有 helper に置換、`CODEX_PENDING_DIR` 定数撤去。
64
+ `appendCodexHookEvent` は `appendHookEvent({host:'codex'})` の薄い wrapper に変更し、
65
+ `summarizeCodexHookEvents` は host:codex でフィルタする wrapper にして既存 export 名互換を維持。
66
+ pending 保存先を `.spotter/codex-pending/` から host-neutral `.spotter/pending/` に移行。
67
+ - **新規 [src/core/hook-event-log.mjs](src/core/hook-event-log.mjs)** (Phase D):
68
+ host-neutral hook event JSONL helper (`appendHookEvent` / `appendHookEventSafe` /
69
+ `summarizeHookEvents` / `hookEventsPath` / schema 定数)。schema は
70
+ `spotter.hook_event.v1`、`host: "claude" | "codex"` フィールドを必須化。
71
+ - **編集 Claude 側 hook 5 種** (Phase D):
72
+ `src/hooks/{session-start,user-prompt,pre-tool-use,stop,session-end}.mjs` に
73
+ `recordClaudeHookEvent` 経由で hook event JSONL に append。各 hook の status / reason /
74
+ durationMs / pendingContextCount / missingTools が `<projectRoot>/.spotter/hook-events.jsonl`
75
+ に時系列で記録される。Codex 側 records と同一ファイル / 同一 schema。
76
+ - **編集 [src/hooks/lib.mjs](src/hooks/lib.mjs)** (Phase D):
77
+ Claude hook 用の `recordClaudeHookEvent` ヘルパ追加 (best-effort、失敗は stderr へ warn のみで
78
+ hook 自体は壊さない)。
79
+ - **編集 [src/cli/diagnostics-cmd.mjs](src/cli/diagnostics-cmd.mjs)** (Phase D):
80
+ `--project DIR` option 追加 (default: cwd)。daemon log の集計に加えて
81
+ `<projectRoot>/.spotter/hook-events.jsonl` も読み、`hookEvents` セクションに
82
+ `byHost` / `byHook` / `byStatus` / `byBackend` / 平均 / 最大 duration を出力。
83
+ - **編集 test/** (Phase A/B/D 合わせて 37 件追加 / 3 件 short-skip 干渉回避):
84
+ test/daemon.test.mjs (Phase A 13 件), test/hooks.test.mjs (Phase B 13 件),
85
+ test/hook-event-log.test.mjs (Phase D 11 件)。フルスイート 320 tests / 319 pass / 1 skip。
86
+
87
+ ### 安全制約 (変更なし)
88
+
89
+ `SPOTTER_PARENT_PID` / `SPOTTER_BACKEND` / `SPOTTER_CHILD_BACKEND` / `agent_id` /
90
+ `source === "startup"` / `.spotter/marker.json` / PID preexist check / 10 秒 Haiku call window
91
+ はすべて v1.4.7 と同じ仕様を維持。daemon の auditor 経路 (`createAuditorBackend` /
92
+ `createCodexCliAuditorBackend`) も無変更。Backend 取り扱い (Phase 5 / v1.4.7 で完了済み) も
93
+ 無変更。Backend error / transport error は引き続き hook が exit 1 + stderr で表面化し、pending
94
+ queue へは混ぜない (silent fallback 禁止)。
95
+
96
+ ### ユーザー側で必要な手順
97
+
98
+ 1. `npm install -g claude-spotter@1.4.8`
99
+ 2. 各プロジェクトで `spotter install` 再実行 (新 hook event JSONL の path 整合のため)
100
+ 3. 既存 `<projectRoot>/.spotter/codex-pending/` ディレクトリは v1.4.8 では参照されなくなる
101
+ (新パスは `.spotter/pending/`)。残存 file は手動削除可、自動 cleanup はしない
102
+ 4. 既存 `<projectRoot>/.spotter/codex-hook-events.jsonl` も v1.4.8 では新規書き込みされず、
103
+ 新ファイルは `.spotter/hook-events.jsonl`。古い JSONL は手動 archive / 削除が望ましい
104
+
105
+ ### 検証
106
+
107
+ - `node --test` 320 tests / 319 pass / 1 skip 緑
108
+ - 実セッション smoke は Spotter 自身のリポジトリでは self-referential 制約のため実施せず。
109
+ 別プロジェクトでの実セッション smoke と数日分 diagnostics は rollout 観測フェーズに回す
110
+
111
+ ## 1.4.7
112
+
113
+ **Claude host の opt-in `next` policy を Codex CLI primary auditor に切り替え (Phase 5)**。
114
+ v1.4.6 までは `SPOTTER_AUDITOR_BACKEND_POLICY=next` を Claude host で立てても
115
+ `policy_next_claude_held_for_phase5` のまま Haiku に張り付いていた。Phase 4 matrix smoke
116
+ (2026-05-06, GeForce 5000 fixture) で `claude.codex-cli=10041ms` /
117
+ `claude.codex-sidecar=12863ms` と Codex CLI が latency 優位、かつ Haiku diagnostics 平均が
118
+ `user_input ~14.3s / turn_end ~16.6s` だったため、Claude host も `next` で Codex CLI を
119
+ 選ぶようにした。Codex host 既定 (`v1.4.3` で固定) と同じ判定軸。
120
+
121
+ ### 変更点
122
+
123
+ - **編集 [src/core/auditor-backend.mjs](src/core/auditor-backend.mjs)**:
124
+ `selectByPolicy` の Claude+`next` 経路を Codex CLI に変更
125
+ (`reason=policy_next_claude_codex_cli`, `compatibility=none`)。`current` policy と
126
+ `SPOTTER_AUDITOR_BACKEND=haiku` 明示時のみ Haiku を維持する。Codex CLI が unavailable /
127
+ timeout / schema invalid / non-zero exit の場合、`createCodexCliAuditorBackend` が
128
+ 既存通り `AuditorBackendError` を投げ、daemon は Haiku に hidden fallback せず
129
+ structured error として hook に伝搬する。
130
+ - **編集 [test/auditor-backend.test.mjs](test/auditor-backend.test.mjs)**:
131
+ Phase 1 用の "held for phase5" 固定を Phase 5 後の挙動 (Claude+`next` →
132
+ `policy_next_claude_codex_cli`) に置き換え、`current` policy が両 host で Haiku を維持する
133
+ test、Claude+`next` で `SPOTTER_AUDITOR_BACKEND=haiku` 明示が依然として Haiku を選ぶ
134
+ 互換 test、`createAuditorBackend` factory が `auto` + Claude + `next` で Codex CLI backend を
135
+ 返す factory-level test を追加。
136
+ - **編集 [docs/SPOTTER_CLAUDE_CONTRACT.md](docs/SPOTTER_CLAUDE_CONTRACT.md)** /
137
+ [docs/archive/SPOTTER_PRIMARY_BACKEND_TODO.md](docs/archive/SPOTTER_PRIMARY_BACKEND_TODO.md) /
138
+ [docs/open-issues.md](docs/open-issues.md):
139
+ Claude host の `current` / `next` policy 表と Phase 5 ゲート、Haiku compatibility が
140
+ `current` policy または `SPOTTER_AUDITOR_BACKEND=haiku` 明示時のみであること、hidden
141
+ fallback 不可を明記。
142
+
143
+ ### 安全制約 (変更なし)
144
+
145
+ `SPOTTER_PARENT_PID`, `SPOTTER_BACKEND`, `SPOTTER_CHILD_BACKEND`, `agent_id`,
146
+ `source === "startup"`, `.spotter/marker.json`, PID preexist check, 10 秒 Haiku call window
147
+ は全て v1.4.6 と同じ仕様を維持。Codex CLI auditor child は引き続き
148
+ `--ephemeral --ignore-user-config --ignore-rules --sandbox read-only` + recursion marker env で
149
+ spawn される。
150
+
151
+ ### ユーザー側で必要な手順
152
+
153
+ 1. `npm install -g claude-spotter@1.4.7`
154
+ 2. Claude host の `next` policy を試したいプロジェクトで
155
+ `SPOTTER_AUDITOR_BACKEND_POLICY=next` をセット (例: shell rc / `.envrc`)
156
+ 3. Codex CLI が PATH にあること、`codex --version` が通ることを確認
157
+ 4. `current` policy (= 既存 Haiku 動作) は明示変更しない限り維持される
158
+
159
+ ### 検証
160
+
161
+ - `node --test` 緑化
162
+ - 実セッション smoke は Spotter 自身のリポジトリでは self-referential 制約があるため
163
+ 実施しない。代替として Phase 4 matrix smoke (2026-05-06) と Phase 5 unit test を gate に
164
+ 使う。別プロジェクトでの実セッション smoke と数日分 diagnostics は Phase 7 rollout 観測で
165
+ 追って計測する。
166
+
3
167
  ## 1.4.6
4
168
 
5
169
  **Codex 初回セッションが空 catalog に依存し得る穴を修正**。v1.4.5 までは
package/README.ja.md CHANGED
@@ -45,6 +45,7 @@ spotter install
45
45
 
46
46
  `v0.3.0` 以降は**プロジェクト単位の明示的 install** を採用しています (v0.2 までの `postinstall` 自動登録はデーモン増殖の主因だったため撤回)。各プロジェクトの `.claude/settings.json` に hook を登録し、そのプロジェクトでの Claude Code セッションのみで有効になります。
47
47
  Codex CLI が使える環境では、同じ `spotter install` が user-level の Codex native hooks も登録します。実際に動くプロジェクトは `spotter install` が作る `.spotter/marker.json` で制限されるため、無関係な Codex セッションでは Spotter は起動しません。
48
+ Codex 側では現行の `[features].hooks = true` を有効化し、互換のため旧 `codex_hooks` diagnostics output も認識します。
48
49
 
49
50
  Spotter を upgrade した後、release note で hook 設定変更が案内されている場合は、各 install 済みプロジェクトで `spotter install` を再実行してください。global package update でコード経路は変わりますが、既存 `.claude/settings.json` の timeout 値は自動では書き換わりません。
50
51
 
@@ -75,8 +76,8 @@ flowchart TD
75
76
  BA --> SH[Stop hook<br/>応答と使用済みツールから最終チェック]
76
77
  SH --> DEC{見落とし<br/>あり?}
77
78
  DEC -->|なし| DONE([完了])
78
- DEC -->|あり| SB[差し戻し<br/>max 1 回<br/>stop_hook_active で自動担保]
79
- SB --> BA2([Bell の補正応答]) --> DONE
79
+ DEC -->|あり| SB[.spotter/pending/ に積む<br/>v1.4.8 deferred delivery]
80
+ SB --> NEXT([次の UserPromptSubmit で<br/>additionalContext として配信])
80
81
  ```
81
82
 
82
83
  ### カタログの収集経路
@@ -178,8 +179,8 @@ Codex CLI auditor の子プロセスは、hook 判定を安く速く保つため
178
179
 
179
180
  ## 既知の制約
180
181
 
181
- - Stop hook は Bell の最初の応答が**出力された後**に発火するため、Spotter Stop で差し戻した場合、ユーザーは「最初の応答 + 補正応答」の 2 連続を見ます (Claude Code hook 仕様による制約)。UserPromptSubmit 段階での先回り検出を精度の軸にしています
182
- - Codex native `Stop` **即時ブロックではなく遅延配送**です。Codex `Stop` で不足ツールを見つけた場合、Spotter は `.spotter/codex-pending/` に指摘を保存し、次の same-session `UserPromptSubmit` で `additionalContext` として提示します。これは Codex の現行 `decision:"block"` が `Stop Blocked` / exit code 1 になりやすい実測結果を避けるためです
182
+ - v1.4.8 以降、Claude / Codex 両 host で `Stop` hook は **遅延配送 (deferred delivery)** に統一されました。`Stop` で見落としツールを検出した場合、Spotter `<projectRoot>/.spotter/pending/<sessionId>.json` に指摘を積み、次の same-session `UserPromptSubmit` で `additionalContext` として配信します。当ターンの最初の応答は transcript にそのまま残るため、`decision:"block"` で補正サイクルを回す方式の「最終応答が補正中心になって元の文脈が迷子」問題が解消します (Codex 側は `Stop Blocked` / exit code 1 回避も兼ねる)
183
+ - pending ファイルは Claude / Codex が同じパス (`.spotter/pending/`) を共有します。host-neutral 設計です
183
184
  - **JSON スキーマ違反は v0.5.0 以降「想定済み異常」として silent pass + session renew で回復**します (role collapse 検知パス、daemon ログに `role_collapse_reset` を残す)。一方 **Haiku timeout は引き続き throw** され、UserPromptSubmit がブロックされてユーザー入力が Bell に届かない症状として顕在化します (timeout は v0.5.0 で 30s、v0.13.1 で 45s に拡張)。timeout の fail-open 化 (pass 扱い) は §0 改訂とセットで今後検討
184
185
 
185
186
  <details>
package/README.md CHANGED
@@ -45,6 +45,7 @@ spotter install
45
45
 
46
46
  Since `v0.3.0`, Spotter requires **explicit per-project install** (the earlier `postinstall` auto-registration was the leading cause of orphan daemons). `spotter install` writes hooks into the project's `.claude/settings.json`; the audit is then active only in Claude Code sessions for that project.
47
47
  When the Codex CLI is available, the same `spotter install` also registers user-level Codex native hooks. Project activation still depends on the same per-project `.spotter/marker.json`, so unrelated Codex sessions do not trigger Spotter.
48
+ For Codex, install enables the current `[features].hooks = true` flag and still recognizes older `codex_hooks` diagnostics output for compatibility.
48
49
 
49
50
  After upgrading Spotter, re-run `spotter install` in each installed project when release notes mention hook setting changes. The global package update changes the code path, but existing `.claude/settings.json` timeout values are not rewritten automatically.
50
51
 
@@ -75,8 +76,8 @@ flowchart TD
75
76
  BA --> SH[Stop hook<br/>Spotter re-audits answer + tools used]
76
77
  SH --> DEC{Missed<br/>tool?}
77
78
  DEC -->|No| DONE([Done])
78
- DEC -->|Yes| SB[Send-back to Bell<br/>max 1 round<br/>guarded by stop_hook_active]
79
- SB --> BA2([Bell's amended answer]) --> DONE
79
+ DEC -->|Yes| SB[Queue finding to .spotter/pending/<br/>v1.4.8 deferred delivery]
80
+ SB --> NEXT([Surfaces as additionalContext<br/>on next UserPromptSubmit])
80
81
  ```
81
82
 
82
83
  ### Catalog discovery
@@ -182,7 +183,7 @@ those values for smoke tests or controlled experiments.
182
183
  ## Known limitations
183
184
 
184
185
  - The `Stop` hook fires **after** Bell's first answer has already been streamed to the user. When Spotter sends Bell back, the user sees both the original answer and the corrected one. Detection accuracy in `UserPromptSubmit` (the *pre-response* stage) is therefore Spotter's primary axis of quality
185
- - Codex native `Stop` is **deferred**, not an immediate block. If Spotter finds a missed tool at Codex `Stop`, it writes the finding to `.spotter/codex-pending/` and surfaces it on the next same-session `UserPromptSubmit` as `additionalContext`. This avoids Codex's current `Stop Blocked` / exit-code-1 behavior for `decision:"block"`
186
+ - `Stop` hook is **deferred** for both Claude and Codex hosts as of v1.4.8. When Spotter finds a missed tool at `Stop`, it appends the finding to `<projectRoot>/.spotter/pending/<sessionId>.json` and surfaces it on the next same-session `UserPromptSubmit` as `additionalContext`. The original assistant message stays as the turn's final transcript entry no `decision:"block"` re-generation cycle. The same pending file is shared by Claude and Codex (host-neutral path)
186
187
  - **Since v0.5.0, JSON schema violations from Haiku are treated as expected-anomalies** (silent pass + session renew, logged as `role_collapse_reset`) — this is the role-collapse recovery path. **Haiku timeouts still throw**, which surfaces as `UserPromptSubmit` blocking the user's prompt from reaching Bell. Timeouts have been raised twice (30s in v0.5.0, 45s in v0.13.1); making timeouts fail-open is deferred until §0 is revisited
187
188
 
188
189
  <details>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.4.6",
3
+ "version": "1.4.9",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
- import { appendFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -18,6 +18,15 @@ import {
18
18
  readStdinJson,
19
19
  requireString,
20
20
  } from '../hooks/lib.mjs';
21
+ import {
22
+ appendPendingContext,
23
+ drainPendingContexts,
24
+ } from '../hooks/pending-context.mjs';
25
+ import {
26
+ appendHookEvent,
27
+ hookEventsPath,
28
+ summarizeHookEvents,
29
+ } from '../core/hook-event-log.mjs';
21
30
 
22
31
  const HERE = dirname(fileURLToPath(import.meta.url));
23
32
  const PACKAGE_ROOT = resolve(HERE, '..', '..');
@@ -26,8 +35,7 @@ const CODEX_HOOK_TIMEOUT_SEC = 60;
26
35
  const DEFAULT_CODEX_HOOK_AUDITOR_TIMEOUT_MS = 20_000;
27
36
  const SHORT_PROMPT_MAX_CHARS = 10;
28
37
  const DEFAULT_CODEX_STOP_SHORT_FINAL_MAX_CHARS = 120;
29
- const CODEX_PENDING_DIR = 'codex-pending';
30
- const CODEX_HOOK_EVENTS_FILE = 'codex-hook-events.jsonl';
38
+ const CODEX_HOOK_FEATURE_NAMES = ['hooks', 'codex_hooks'];
31
39
 
32
40
  const CODEX_HOOK_USAGE = `spotter codex-hook — Codex native hook adapter
33
41
 
@@ -114,7 +122,7 @@ export async function runCodexUserPromptSubmitHook({
114
122
  const startedAt = Date.now();
115
123
 
116
124
  const prompt = requireString(input, 'prompt');
117
- const contexts = await drainCodexPendingContexts({ projectRoot, sessionId: codexSessionId(input) });
125
+ const contexts = await drainPendingContexts({ projectRoot, sessionId: codexSessionId(input) });
118
126
  if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) {
119
127
  await recordCodexHookEventSafe(recordHookEventFn, {
120
128
  projectRoot,
@@ -213,7 +221,7 @@ export async function runCodexStopHook({
213
221
  } catch (err) {
214
222
  const errorText = formatCodexHookBackendError(err);
215
223
  writeError(`${errorText}\n`);
216
- await appendCodexPendingContext({
224
+ await appendPendingContext({
217
225
  projectRoot,
218
226
  sessionId: codexSessionId(input),
219
227
  text: errorText,
@@ -248,7 +256,7 @@ export async function runCodexStopHook({
248
256
  return;
249
257
  }
250
258
 
251
- await appendCodexPendingContext({
259
+ await appendPendingContext({
252
260
  projectRoot,
253
261
  sessionId: codexSessionId(input),
254
262
  text: formatTransparentBlockReason(legacyResultFromJudgment(judgment).missing_tools),
@@ -357,7 +365,8 @@ export async function codexHookDiagnostics({ codexHome = defaultCodexHome(), pro
357
365
  const features = spawnSyncFn('codex', ['features', 'list'], { encoding: 'utf8', maxBuffer: 1024 * 1024 });
358
366
  const featureOutput = [features.stdout, features.stderr].filter(Boolean).join('\n');
359
367
  const hooks = await loadJson(join(codexHome, 'hooks.json'));
360
- const codexHooksFeature = /^codex_hooks\s+\S+\s+true\b/m.test(featureOutput) ? 'enabled' : 'not-enabled';
368
+ const evidence = featureOutput.split('\n').find((line) => isEnabledCodexHookFeatureLine(line)) ?? null;
369
+ const codexHooksFeature = evidence ? 'enabled' : 'not-enabled';
361
370
  const installed = {
362
371
  sessionStart: hookState(hooks, 'SessionStart', 'codex-hook session-start'),
363
372
  userPromptSubmit: hookState(hooks, 'UserPromptSubmit', 'codex-hook user-prompt-submit'),
@@ -375,7 +384,7 @@ export async function codexHookDiagnostics({ codexHome = defaultCodexHome(), pro
375
384
  codexHome,
376
385
  hooksPath: join(codexHome, 'hooks.json'),
377
386
  installedHooks: installed,
378
- evidence: featureOutput.split('\n').find((line) => line.trim().startsWith('codex_hooks')) ?? null,
387
+ evidence,
379
388
  runtime: runtimeProjectRoot
380
389
  ? await summarizeCodexHookEvents({ projectRoot: runtimeProjectRoot })
381
390
  : null,
@@ -450,60 +459,17 @@ function codexSessionId(payload) {
450
459
  return typeof value === 'string' && value.length > 0 ? value : null;
451
460
  }
452
461
 
453
- function codexPendingPath({ projectRoot, sessionId }) {
454
- if (!sessionId) return null;
455
- const clean = sessionId.replace(/[^A-Za-z0-9_-]/g, '');
456
- if (!clean) return null;
457
- return join(projectRoot, '.spotter', CODEX_PENDING_DIR, `${clean}.json`);
458
- }
459
-
460
- async function appendCodexPendingContext({ projectRoot, sessionId, text }) {
461
- const path = codexPendingPath({ projectRoot, sessionId });
462
- const value = String(text ?? '').trim();
463
- if (!path || !value) return false;
464
- const contexts = await readCodexPendingContexts(path);
465
- if (!contexts.includes(value)) contexts.push(value);
466
- await mkdir(dirname(path), { recursive: true });
467
- await writeFile(path, JSON.stringify(contexts, null, 2) + '\n', 'utf8');
468
- return true;
469
- }
470
-
471
- async function drainCodexPendingContexts({ projectRoot, sessionId }) {
472
- const path = codexPendingPath({ projectRoot, sessionId });
473
- if (!path) return [];
474
- const contexts = await readCodexPendingContexts(path);
475
- if (contexts.length > 0) {
476
- try {
477
- await unlink(path);
478
- } catch (err) {
479
- if (err.code !== 'ENOENT') throw err;
480
- }
481
- }
482
- return contexts;
483
- }
484
-
485
- async function readCodexPendingContexts(path) {
486
- try {
487
- const raw = await readFile(path, 'utf8');
488
- const parsed = JSON.parse(raw);
489
- return Array.isArray(parsed)
490
- ? parsed.map((item) => typeof item === 'string' ? item.trim() : '').filter(Boolean)
491
- : [];
492
- } catch (err) {
493
- if (err.code === 'ENOENT') return [];
494
- throw err;
495
- }
496
- }
462
+ // Phase B (hook parity, 2026-05-08): pending-context helpers were moved to
463
+ // `src/hooks/pending-context.mjs` and the on-disk path migrated from
464
+ // `.spotter/codex-pending/` to host-neutral `.spotter/pending/`. The Claude Stop hook
465
+ // now writes to the same queue.
497
466
 
467
+ // Phase D (hook parity, 2026-05-08): Codex hook events now go through the host-neutral
468
+ // `appendHookEvent` so Claude / Codex events live in the same `.spotter/hook-events.jsonl`.
469
+ // Kept as an internal wrapper so existing call sites (and the `recordHookEventFn` DI knob
470
+ // in tests) can stay on the same shape.
498
471
  async function appendCodexHookEvent({ projectRoot, event }) {
499
- const value = {
500
- schema: 'spotter.codex_hook_event.v1',
501
- timestamp: new Date().toISOString(),
502
- ...event,
503
- };
504
- const path = codexHookEventsPath(projectRoot);
505
- await mkdir(dirname(path), { recursive: true });
506
- await appendFile(path, JSON.stringify(value) + '\n', 'utf8');
472
+ await appendHookEvent({ projectRoot, host: 'codex', event });
507
473
  }
508
474
 
509
475
  async function recordCodexHookEventSafe(recordHookEventFn, input, writeError) {
@@ -514,17 +480,24 @@ async function recordCodexHookEventSafe(recordHookEventFn, input, writeError) {
514
480
  }
515
481
  }
516
482
 
483
+ // Phase D (hook parity, 2026-05-08): Codex `--project` diagnostics now read the host-neutral
484
+ // `<projectRoot>/.spotter/hook-events.jsonl` and filter to `host:"codex"` so the existing
485
+ // `codex-hook diagnostics` shape (counts of just Codex events) stays intact.
517
486
  export async function summarizeCodexHookEvents({ projectRoot, readFileFn = readFile } = {}) {
518
487
  if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
519
488
  throw new TypeError('summarizeCodexHookEvents: projectRoot must be a non-empty string');
520
489
  }
490
+ const full = await summarizeHookEvents({ projectRoot, readFileFn });
491
+ // Re-aggregate with a Codex-only filter so the legacy diagnostics caller doesn't see Claude
492
+ // entries pulled in from the unified file. We re-read the JSONL ourselves to keep counts
493
+ // exact (summarizeHookEvents already iterated, but it folded Claude entries in).
521
494
  const summary = {
522
- schema: 'spotter.codex_hook_events_summary.v1',
495
+ schema: 'spotter.hook_events_summary.v1',
523
496
  projectRoot,
524
- logPath: codexHookEventsPath(projectRoot),
525
- exists: false,
497
+ logPath: hookEventsPath(projectRoot),
498
+ exists: full.exists,
526
499
  events: 0,
527
- parseErrors: 0,
500
+ parseErrors: full.parseErrors,
528
501
  byHook: {},
529
502
  byStatus: {},
530
503
  byBackend: {},
@@ -532,19 +505,19 @@ export async function summarizeCodexHookEvents({ projectRoot, readFileFn = readF
532
505
  maxDurationMs: 0,
533
506
  recent: [],
534
507
  };
508
+ if (!summary.exists) return summary;
535
509
  let totalDurationMs = 0;
536
510
  try {
537
511
  const raw = await readFileFn(summary.logPath, 'utf8');
538
- summary.exists = true;
539
512
  for (const line of raw.split(/\r?\n/)) {
540
513
  if (!line.trim()) continue;
541
514
  let event;
542
515
  try {
543
516
  event = JSON.parse(line);
544
517
  } catch {
545
- summary.parseErrors += 1;
546
518
  continue;
547
519
  }
520
+ if (event.host !== 'codex') continue;
548
521
  summary.events += 1;
549
522
  incrementCounter(summary.byHook, event.hook ?? 'unknown');
550
523
  incrementCounter(summary.byStatus, event.status ?? 'unknown');
@@ -577,10 +550,6 @@ function compactCodexHookEvent(event) {
577
550
  };
578
551
  }
579
552
 
580
- function codexHookEventsPath(projectRoot) {
581
- return join(projectRoot, '.spotter', CODEX_HOOK_EVENTS_FILE);
582
- }
583
-
584
553
  function incrementCounter(counter, key) {
585
554
  counter[key] = (counter[key] ?? 0) + 1;
586
555
  }
@@ -686,7 +655,7 @@ function isSpotterCodexHook(hook) {
686
655
  async function ensureCodexHooksFeature(configPath) {
687
656
  let raw = '';
688
657
  if (existsSync(configPath)) raw = await readFile(configPath, 'utf8');
689
- if (/^\s*codex_hooks\s*=\s*true\s*$/m.test(raw)) return 'already-enabled';
658
+ if (/^\s*hooks\s*=\s*true\s*$/m.test(raw)) return 'already-enabled';
690
659
  const next = enableCodexHooksFeature(raw);
691
660
  await mkdir(dirname(configPath), { recursive: true });
692
661
  await writeFile(configPath, next, 'utf8');
@@ -696,7 +665,7 @@ async function ensureCodexHooksFeature(configPath) {
696
665
  function enableCodexHooksFeature(raw) {
697
666
  const text = raw.trimEnd();
698
667
  if (!/^\s*\[features\]\s*$/m.test(text)) {
699
- return `${text}${text ? '\n\n' : ''}[features]\ncodex_hooks = true\n`;
668
+ return `${text}${text ? '\n\n' : ''}[features]\nhooks = true\n`;
700
669
  }
701
670
  const lines = text.split('\n');
702
671
  let inFeatures = false;
@@ -711,15 +680,23 @@ function enableCodexHooksFeature(raw) {
711
680
  inFeatures = /^\s*\[features\]\s*$/.test(line);
712
681
  continue;
713
682
  }
714
- if (inFeatures && /^\s*codex_hooks\s*=/.test(line)) {
715
- lines[index] = 'codex_hooks = true';
683
+ if (inFeatures && /^\s*hooks\s*=/.test(line)) {
684
+ lines[index] = 'hooks = true';
716
685
  return `${lines.join('\n')}\n`;
717
686
  }
718
687
  }
719
- lines.splice(insertAt, 0, 'codex_hooks = true');
688
+ lines.splice(insertAt, 0, 'hooks = true');
720
689
  return `${lines.join('\n')}\n`;
721
690
  }
722
691
 
692
+ function isEnabledCodexHookFeatureLine(line) {
693
+ const trimmed = String(line ?? '').trim();
694
+ return CODEX_HOOK_FEATURE_NAMES.some((name) => {
695
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
696
+ return new RegExp(`^${escaped}\\s+\\S+\\s+true\\b`).test(trimmed);
697
+ });
698
+ }
699
+
723
700
  function hookState(settings, event, commandFragment) {
724
701
  const groups = settings?.hooks?.[event];
725
702
  if (!Array.isArray(groups)) return 'not-installed';
@@ -1,10 +1,14 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { defaultDaemonLogDir, summarizeDaemonLogs } from '../core/daemon-log-diagnostics.mjs';
3
+ import { summarizeHookEvents } from '../core/hook-event-log.mjs';
3
4
 
4
5
  const DIAGNOSTICS_USAGE = `spotter diagnostics — read-only operational diagnostics
5
6
 
6
7
  Usage:
7
- spotter diagnostics logs [--log-dir DIR] [--json]
8
+ spotter diagnostics logs [--log-dir DIR] [--project DIR] [--json]
9
+
10
+ --log-dir daemon log directory (default: ~/.spotter/runtime)
11
+ --project project root for hook-events.jsonl (default: cwd)
8
12
  `;
9
13
 
10
14
  export async function runDiagnosticsCommand({ argv = process.argv.slice(2) } = {}) {
@@ -20,15 +24,21 @@ export async function runDiagnosticsCommand({ argv = process.argv.slice(2) } = {
20
24
  export async function runDiagnosticsLogsCommand({
21
25
  argv = [],
22
26
  summarizeDaemonLogsFn = summarizeDaemonLogs,
27
+ summarizeHookEventsFn = summarizeHookEvents,
23
28
  writeOutput = (text) => process.stdout.write(text),
24
29
  } = {}) {
25
30
  const opts = parseLogsArgs(argv);
26
31
  const summary = await summarizeDaemonLogsFn({ logDir: opts.logDir });
32
+ // Phase D (hook parity, 2026-05-08): hook-event JSONL read alongside daemon log so
33
+ // the hook-side observations (skip reasons, drained pending counts, transport errors
34
+ // that never reach the daemon) surface in the same diagnostics output.
35
+ const hookEvents = await summarizeHookEventsFn({ projectRoot: opts.projectRoot });
36
+ const merged = { ...summary, hookEvents };
27
37
  if (opts.json) {
28
- writeOutput(JSON.stringify(summary, null, 2) + '\n');
38
+ writeOutput(JSON.stringify(merged, null, 2) + '\n');
29
39
  return;
30
40
  }
31
- writeOutput(formatDaemonLogSummary(summary));
41
+ writeOutput(formatDaemonLogSummary(merged));
32
42
  }
33
43
 
34
44
  export function formatDaemonLogSummary(summary) {
@@ -80,12 +90,40 @@ export function formatDaemonLogSummary(summary) {
80
90
  ` codex_risk_check: dispatched=${summary.codexRiskCheck.dispatched}, disabled_skips=${summary.codexRiskCheck.disabledSkips}, no_project_skips=${summary.codexRiskCheck.noProjectRootSkips}, failures=${summary.codexRiskCheck.dispatchFailures}`
81
91
  );
82
92
 
93
+ // Phase D (hook parity): host-neutral hook-events.jsonl summary if present.
94
+ const hookEvents = summary.hookEvents;
95
+ if (hookEvents) {
96
+ if (!hookEvents.exists) {
97
+ lines.push(` hook-events.jsonl: not present (path=${hookEvents.logPath})`);
98
+ } else {
99
+ lines.push(
100
+ ` hook-events.jsonl: events=${hookEvents.events}, parse_errors=${hookEvents.parseErrors}, avg=${hookEvents.averageDurationMs}ms, max=${hookEvents.maxDurationMs}ms`
101
+ );
102
+ const byHost = formatCounter(hookEvents.byHost);
103
+ if (byHost) lines.push(` by host: ${byHost}`);
104
+ const byHook = formatCounter(hookEvents.byHook);
105
+ if (byHook) lines.push(` by hook: ${byHook}`);
106
+ const byStatus = formatCounter(hookEvents.byStatus);
107
+ if (byStatus) lines.push(` by status: ${byStatus}`);
108
+ const byBackend = formatCounter(hookEvents.byBackend);
109
+ if (byBackend) lines.push(` by backend: ${byBackend}`);
110
+ }
111
+ }
112
+
83
113
  return lines.join('\n') + '\n';
84
114
  }
85
115
 
116
+ function formatCounter(counter) {
117
+ if (!counter || typeof counter !== 'object') return '';
118
+ const entries = Object.entries(counter).sort(([a], [b]) => a.localeCompare(b));
119
+ if (entries.length === 0) return '';
120
+ return entries.map(([k, v]) => `${k}=${v}`).join(', ');
121
+ }
122
+
86
123
  function parseLogsArgs(argv) {
87
124
  const opts = {
88
125
  logDir: defaultDaemonLogDir(),
126
+ projectRoot: process.cwd(),
89
127
  json: false,
90
128
  };
91
129
  for (let index = 0; index < argv.length; index += 1) {
@@ -94,6 +132,10 @@ function parseLogsArgs(argv) {
94
132
  opts.logDir = resolve(requireValue(argv, (index += 1), '--log-dir'));
95
133
  continue;
96
134
  }
135
+ if (arg === '--project') {
136
+ opts.projectRoot = resolve(requireValue(argv, (index += 1), '--project'));
137
+ continue;
138
+ }
97
139
  if (arg === '--json') {
98
140
  opts.json = true;
99
141
  continue;
@@ -175,11 +175,21 @@ function selectByPolicy({ hostAgent, policy, projectConfig }) {
175
175
  };
176
176
  }
177
177
  if (hostAgent === 'claude') {
178
+ // Phase 5: Claude host opt-in `next` policy promotes the primary auditor backend
179
+ // from Haiku to Codex CLI. Phase 4 matrix smoke (2026-05-06, GeForce 5000 fixture)
180
+ // measured `claude.codex-cli=10041ms` vs `claude.codex-sidecar=12863ms` and Haiku
181
+ // diagnostics averaged `user_input ~14.3s / turn_end ~16.6s`, so Codex CLI wins on
182
+ // latency without giving up schema-fixed JSON judgment. Hidden fallback is
183
+ // forbidden — when codex-cli is unavailable / times out / exits non-zero,
184
+ // `createCodexCliAuditorBackend` throws `AuditorBackendError` and the daemon
185
+ // surfaces the structured error instead of dropping back to Haiku.
186
+ // Haiku stays reachable only via `current` policy or
187
+ // `SPOTTER_AUDITOR_BACKEND=haiku`.
178
188
  return {
179
- backend: 'haiku',
180
- mode: 'compatibility_haiku',
181
- compatibility: 'current_haiku',
182
- reason: 'policy_next_claude_held_for_phase5',
189
+ backend: 'codex-cli',
190
+ mode: 'codex-cli',
191
+ compatibility: 'none',
192
+ reason: 'policy_next_claude_codex_cli',
183
193
  };
184
194
  }
185
195
  throw new AuditorBackendError(