claude-spotter 1.4.5 → 1.4.8
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 +160 -0
- package/README.ja.md +5 -5
- package/README.md +4 -4
- package/bin/spotter.mjs +0 -0
- package/package.json +1 -1
- package/src/cli/codex-hook-cmd.mjs +35 -68
- package/src/cli/diagnostics-cmd.mjs +45 -3
- package/src/cli/install.mjs +18 -6
- package/src/core/auditor-backend.mjs +14 -4
- package/src/core/hook-event-log.mjs +146 -0
- package/src/daemon/daemon.mjs +52 -0
- package/src/hooks/lib.mjs +15 -0
- package/src/hooks/pending-context.mjs +65 -0
- package/src/hooks/pre-tool-use.mjs +56 -6
- package/src/hooks/session-end.mjs +33 -4
- package/src/hooks/session-start.mjs +16 -1
- package/src/hooks/stop.mjs +86 -12
- package/src/hooks/user-prompt.mjs +94 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,165 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.8
|
|
4
|
+
|
|
5
|
+
**Hook 挙動 parity (Codex → Claude) 移植**。Codex 側で確定していた 3 つの hook 挙動 — Stop
|
|
6
|
+
short-skip / Stop deferred delivery / hook event JSONL ログ — を Claude 側にも適用し、
|
|
7
|
+
両 host で同じ思想で動くよう揃えた。`decision:"block"` は Claude hook から完全撤去された。
|
|
8
|
+
|
|
9
|
+
### 変更点
|
|
10
|
+
|
|
11
|
+
- **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)** (Phase A):
|
|
12
|
+
`handleTurnEnd` 冒頭に short-final + 0 used_tools の skip 分岐を追加。最終応答が ≤120 chars
|
|
13
|
+
(code-point 単位) かつ used_tools 0 件のとき auditor を呼ばずに
|
|
14
|
+
`{pass:true, reason:"short_final_no_tools"}` を即返す。`SPOTTER_STOP_SHORT_FINAL_MAX_CHARS`
|
|
15
|
+
で閾値変更、`<= 0` で機能無効化。Codex 側 `shouldSkipShortCodexStop` と同じ判定軸。
|
|
16
|
+
pure helper `shouldSkipShortStop` / `resolveStopShortFinalMaxChars` を export。
|
|
17
|
+
- **編集 [src/hooks/stop.mjs](src/hooks/stop.mjs)** (Phase B):
|
|
18
|
+
`decision:"block"` を撤去。daemon が `pass:false` を返したら、
|
|
19
|
+
`<projectRoot>/.spotter/pending/<sessionId>.json` に指摘テキスト (formatTransparentBlockReason
|
|
20
|
+
の同じ wording) を append し、stdout は空のまま exit 0。次の UserPromptSubmit が drain して
|
|
21
|
+
`additionalContext` で配信する。`stop_hook_active:true` の早期 pass は維持。
|
|
22
|
+
- **編集 [src/hooks/user-prompt.mjs](src/hooks/user-prompt.mjs)** (Phase B):
|
|
23
|
+
入口で `<projectRoot>/.spotter/pending/<sessionId>.json` を drain → `additionalContext` に統合。
|
|
24
|
+
daemon の `pass:false` 結果と pending drain は同じ `additionalContext` に合体。短プロンプト
|
|
25
|
+
早期 return 経路でも drain は走るので pending が一時返答に詰まらない。
|
|
26
|
+
- **新規 [src/hooks/pending-context.mjs](src/hooks/pending-context.mjs)** (Phase B):
|
|
27
|
+
共有 pending queue helper (`pendingPath` / `appendPendingContext` / `drainPendingContexts` /
|
|
28
|
+
`readPendingContexts`)。Claude / Codex 両 host から同じ実装を通る。pending file は
|
|
29
|
+
`<projectRoot>/.spotter/pending/<sanitized-id>.json`、JSON 配列形式、識別 dedupe。
|
|
30
|
+
- **編集 [src/cli/codex-hook-cmd.mjs](src/cli/codex-hook-cmd.mjs)** (Phase B + Phase D):
|
|
31
|
+
Codex 側 private `codexPendingPath` / `appendCodexPendingContext` / `drainCodexPendingContexts`
|
|
32
|
+
/ `readCodexPendingContexts` を共有 helper に置換、`CODEX_PENDING_DIR` 定数撤去。
|
|
33
|
+
`appendCodexHookEvent` は `appendHookEvent({host:'codex'})` の薄い wrapper に変更し、
|
|
34
|
+
`summarizeCodexHookEvents` は host:codex でフィルタする wrapper にして既存 export 名互換を維持。
|
|
35
|
+
pending 保存先を `.spotter/codex-pending/` から host-neutral `.spotter/pending/` に移行。
|
|
36
|
+
- **新規 [src/core/hook-event-log.mjs](src/core/hook-event-log.mjs)** (Phase D):
|
|
37
|
+
host-neutral hook event JSONL helper (`appendHookEvent` / `appendHookEventSafe` /
|
|
38
|
+
`summarizeHookEvents` / `hookEventsPath` / schema 定数)。schema は
|
|
39
|
+
`spotter.hook_event.v1`、`host: "claude" | "codex"` フィールドを必須化。
|
|
40
|
+
- **編集 Claude 側 hook 5 種** (Phase D):
|
|
41
|
+
`src/hooks/{session-start,user-prompt,pre-tool-use,stop,session-end}.mjs` に
|
|
42
|
+
`recordClaudeHookEvent` 経由で hook event JSONL に append。各 hook の status / reason /
|
|
43
|
+
durationMs / pendingContextCount / missingTools が `<projectRoot>/.spotter/hook-events.jsonl`
|
|
44
|
+
に時系列で記録される。Codex 側 records と同一ファイル / 同一 schema。
|
|
45
|
+
- **編集 [src/hooks/lib.mjs](src/hooks/lib.mjs)** (Phase D):
|
|
46
|
+
Claude hook 用の `recordClaudeHookEvent` ヘルパ追加 (best-effort、失敗は stderr へ warn のみで
|
|
47
|
+
hook 自体は壊さない)。
|
|
48
|
+
- **編集 [src/cli/diagnostics-cmd.mjs](src/cli/diagnostics-cmd.mjs)** (Phase D):
|
|
49
|
+
`--project DIR` option 追加 (default: cwd)。daemon log の集計に加えて
|
|
50
|
+
`<projectRoot>/.spotter/hook-events.jsonl` も読み、`hookEvents` セクションに
|
|
51
|
+
`byHost` / `byHook` / `byStatus` / `byBackend` / 平均 / 最大 duration を出力。
|
|
52
|
+
- **編集 test/** (Phase A/B/D 合わせて 37 件追加 / 3 件 short-skip 干渉回避):
|
|
53
|
+
test/daemon.test.mjs (Phase A 13 件), test/hooks.test.mjs (Phase B 13 件),
|
|
54
|
+
test/hook-event-log.test.mjs (Phase D 11 件)。フルスイート 320 tests / 319 pass / 1 skip。
|
|
55
|
+
|
|
56
|
+
### 安全制約 (変更なし)
|
|
57
|
+
|
|
58
|
+
`SPOTTER_PARENT_PID` / `SPOTTER_BACKEND` / `SPOTTER_CHILD_BACKEND` / `agent_id` /
|
|
59
|
+
`source === "startup"` / `.spotter/marker.json` / PID preexist check / 10 秒 Haiku call window
|
|
60
|
+
はすべて v1.4.7 と同じ仕様を維持。daemon の auditor 経路 (`createAuditorBackend` /
|
|
61
|
+
`createCodexCliAuditorBackend`) も無変更。Backend 取り扱い (Phase 5 / v1.4.7 で完了済み) も
|
|
62
|
+
無変更。Backend error / transport error は引き続き hook が exit 1 + stderr で表面化し、pending
|
|
63
|
+
queue へは混ぜない (silent fallback 禁止)。
|
|
64
|
+
|
|
65
|
+
### ユーザー側で必要な手順
|
|
66
|
+
|
|
67
|
+
1. `npm install -g claude-spotter@1.4.8`
|
|
68
|
+
2. 各プロジェクトで `spotter install` 再実行 (新 hook event JSONL の path 整合のため)
|
|
69
|
+
3. 既存 `<projectRoot>/.spotter/codex-pending/` ディレクトリは v1.4.8 では参照されなくなる
|
|
70
|
+
(新パスは `.spotter/pending/`)。残存 file は手動削除可、自動 cleanup はしない
|
|
71
|
+
4. 既存 `<projectRoot>/.spotter/codex-hook-events.jsonl` も v1.4.8 では新規書き込みされず、
|
|
72
|
+
新ファイルは `.spotter/hook-events.jsonl`。古い JSONL は手動 archive / 削除が望ましい
|
|
73
|
+
|
|
74
|
+
### 検証
|
|
75
|
+
|
|
76
|
+
- `node --test` 320 tests / 319 pass / 1 skip 緑
|
|
77
|
+
- 実セッション smoke は Spotter 自身のリポジトリでは self-referential 制約のため実施せず。
|
|
78
|
+
別プロジェクトでの実セッション smoke と数日分 diagnostics は rollout 観測フェーズに回す
|
|
79
|
+
|
|
80
|
+
## 1.4.7
|
|
81
|
+
|
|
82
|
+
**Claude host の opt-in `next` policy を Codex CLI primary auditor に切り替え (Phase 5)**。
|
|
83
|
+
v1.4.6 までは `SPOTTER_AUDITOR_BACKEND_POLICY=next` を Claude host で立てても
|
|
84
|
+
`policy_next_claude_held_for_phase5` のまま Haiku に張り付いていた。Phase 4 matrix smoke
|
|
85
|
+
(2026-05-06, GeForce 5000 fixture) で `claude.codex-cli=10041ms` /
|
|
86
|
+
`claude.codex-sidecar=12863ms` と Codex CLI が latency 優位、かつ Haiku diagnostics 平均が
|
|
87
|
+
`user_input ~14.3s / turn_end ~16.6s` だったため、Claude host も `next` で Codex CLI を
|
|
88
|
+
選ぶようにした。Codex host 既定 (`v1.4.3` で固定) と同じ判定軸。
|
|
89
|
+
|
|
90
|
+
### 変更点
|
|
91
|
+
|
|
92
|
+
- **編集 [src/core/auditor-backend.mjs](src/core/auditor-backend.mjs)**:
|
|
93
|
+
`selectByPolicy` の Claude+`next` 経路を Codex CLI に変更
|
|
94
|
+
(`reason=policy_next_claude_codex_cli`, `compatibility=none`)。`current` policy と
|
|
95
|
+
`SPOTTER_AUDITOR_BACKEND=haiku` 明示時のみ Haiku を維持する。Codex CLI が unavailable /
|
|
96
|
+
timeout / schema invalid / non-zero exit の場合、`createCodexCliAuditorBackend` が
|
|
97
|
+
既存通り `AuditorBackendError` を投げ、daemon は Haiku に hidden fallback せず
|
|
98
|
+
structured error として hook に伝搬する。
|
|
99
|
+
- **編集 [test/auditor-backend.test.mjs](test/auditor-backend.test.mjs)**:
|
|
100
|
+
Phase 1 用の "held for phase5" 固定を Phase 5 後の挙動 (Claude+`next` →
|
|
101
|
+
`policy_next_claude_codex_cli`) に置き換え、`current` policy が両 host で Haiku を維持する
|
|
102
|
+
test、Claude+`next` で `SPOTTER_AUDITOR_BACKEND=haiku` 明示が依然として Haiku を選ぶ
|
|
103
|
+
互換 test、`createAuditorBackend` factory が `auto` + Claude + `next` で Codex CLI backend を
|
|
104
|
+
返す factory-level test を追加。
|
|
105
|
+
- **編集 [docs/SPOTTER_CLAUDE_CONTRACT.md](docs/SPOTTER_CLAUDE_CONTRACT.md)** /
|
|
106
|
+
[docs/archive/SPOTTER_PRIMARY_BACKEND_TODO.md](docs/archive/SPOTTER_PRIMARY_BACKEND_TODO.md) /
|
|
107
|
+
[docs/open-issues.md](docs/open-issues.md):
|
|
108
|
+
Claude host の `current` / `next` policy 表と Phase 5 ゲート、Haiku compatibility が
|
|
109
|
+
`current` policy または `SPOTTER_AUDITOR_BACKEND=haiku` 明示時のみであること、hidden
|
|
110
|
+
fallback 不可を明記。
|
|
111
|
+
|
|
112
|
+
### 安全制約 (変更なし)
|
|
113
|
+
|
|
114
|
+
`SPOTTER_PARENT_PID`, `SPOTTER_BACKEND`, `SPOTTER_CHILD_BACKEND`, `agent_id`,
|
|
115
|
+
`source === "startup"`, `.spotter/marker.json`, PID preexist check, 10 秒 Haiku call window
|
|
116
|
+
は全て v1.4.6 と同じ仕様を維持。Codex CLI auditor child は引き続き
|
|
117
|
+
`--ephemeral --ignore-user-config --ignore-rules --sandbox read-only` + recursion marker env で
|
|
118
|
+
spawn される。
|
|
119
|
+
|
|
120
|
+
### ユーザー側で必要な手順
|
|
121
|
+
|
|
122
|
+
1. `npm install -g claude-spotter@1.4.7`
|
|
123
|
+
2. Claude host の `next` policy を試したいプロジェクトで
|
|
124
|
+
`SPOTTER_AUDITOR_BACKEND_POLICY=next` をセット (例: shell rc / `.envrc`)
|
|
125
|
+
3. Codex CLI が PATH にあること、`codex --version` が通ることを確認
|
|
126
|
+
4. `current` policy (= 既存 Haiku 動作) は明示変更しない限り維持される
|
|
127
|
+
|
|
128
|
+
### 検証
|
|
129
|
+
|
|
130
|
+
- `node --test` 緑化
|
|
131
|
+
- 実セッション smoke は Spotter 自身のリポジトリでは self-referential 制約があるため
|
|
132
|
+
実施しない。代替として Phase 4 matrix smoke (2026-05-06) と Phase 5 unit test を gate に
|
|
133
|
+
使う。別プロジェクトでの実セッション smoke と数日分 diagnostics は Phase 7 rollout 観測で
|
|
134
|
+
追って計測する。
|
|
135
|
+
|
|
136
|
+
## 1.4.6
|
|
137
|
+
|
|
138
|
+
**Codex 初回セッションが空 catalog に依存し得る穴を修正**。v1.4.5 までは
|
|
139
|
+
`spotter install` が Codex hooks を登録しても Codex host-local DB は seed せず、
|
|
140
|
+
初回 Codex セッションの `SessionStart` が detached `spotter db refresh --host-agent codex`
|
|
141
|
+
を起動するだけだった。そのため最初の `UserPromptSubmit` が refresh 完了前に走ると
|
|
142
|
+
`.spotter/tool-db.codex.json` が空 / 未作成のまま Codex auditor が動き得た。
|
|
143
|
+
|
|
144
|
+
### 変更点
|
|
145
|
+
|
|
146
|
+
- **編集 [src/cli/install.mjs](src/cli/install.mjs)**:
|
|
147
|
+
Codex CLI が見つかり Codex hooks を登録した project install では、Claude DB seed に続いて
|
|
148
|
+
`refresh({hostAgent:"codex"})` も同期実行し、`.spotter/tool-db.codex.json` と
|
|
149
|
+
`~/.spotter/tool-db.codex.json` を作るようにした。以降の Codex `SessionStart` bg refresh は
|
|
150
|
+
drift 追従用として残す。Codex CLI が見つからなかった場合の next steps も、Codex hooks が
|
|
151
|
+
active ではないことと `codex --version` が通る環境で再実行すべきことを明示する。
|
|
152
|
+
- **編集 [test/install.test.mjs](test/install.test.mjs)**:
|
|
153
|
+
Codex hooks 登録時に Claude / Codex の両 host DB refresh が順に走ること、Codex CLI
|
|
154
|
+
unavailable 時は Codex seed へ進まないことを固定。
|
|
155
|
+
- **編集 README / README.ja / docs**:
|
|
156
|
+
Codex DB は install 時に初回 seed され、SessionStart refresh は以後の drift 追従であることを明記。
|
|
157
|
+
|
|
158
|
+
### ユーザー側で必要な手順
|
|
159
|
+
|
|
160
|
+
1. `npm install -g claude-spotter@1.4.6`
|
|
161
|
+
2. Codex を使う各プロジェクトで `spotter install` を再実行
|
|
162
|
+
|
|
3
163
|
## 1.4.5
|
|
4
164
|
|
|
5
165
|
**Codex global tool-db を Claude global tool-db から分離**。v1.4.4 までは local DB は
|
package/README.ja.md
CHANGED
|
@@ -75,8 +75,8 @@ flowchart TD
|
|
|
75
75
|
BA --> SH[Stop hook<br/>応答と使用済みツールから最終チェック]
|
|
76
76
|
SH --> DEC{見落とし<br/>あり?}
|
|
77
77
|
DEC -->|なし| DONE([完了])
|
|
78
|
-
DEC -->|あり| SB[
|
|
79
|
-
SB -->
|
|
78
|
+
DEC -->|あり| SB[.spotter/pending/ に積む<br/>v1.4.8 deferred delivery]
|
|
79
|
+
SB --> NEXT([次の UserPromptSubmit で<br/>additionalContext として配信])
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
### カタログの収集経路
|
|
@@ -107,7 +107,7 @@ flowchart LR
|
|
|
107
107
|
|
|
108
108
|
監査対象のツール (name + description) は host-local に分離されます。Claude は `<project>/.spotter/tool-db.json`、Codex は `<project>/.spotter/tool-db.codex.json` を使います。**daemon が監査に使うのは Claude local DB のみ**で、Codex native hooks は Codex local DB を読みます。グローバル description cache も host ごとに分離され、Claude は `~/.spotter/tool-db.json`、Codex は `~/.spotter/tool-db.codex.json` を使います。これらは同じ host の他プロジェクト間でだけ再利用され、監査入力には混ぜません。各 host-local DB は **その host の現時点の discovery 結果と一致** (refresh 時に prune される) するため、別プロジェクトや別 host のツールリストで上書きされることはありません。
|
|
109
109
|
|
|
110
|
-
**`spotter install` が Claude catalog の初回 seed を自動実行し、Claude Code セッション起動ごとに SessionStart hook が bg で `spotter db refresh` を走らせる**ため、Claude 通常運用で手動コマンドを叩く必要はありません。Codex CLI が使える環境では、同じ `spotter install` が Codex native hooks
|
|
110
|
+
**`spotter install` が Claude catalog の初回 seed を自動実行し、Claude Code セッション起動ごとに SessionStart hook が bg で `spotter db refresh` を走らせる**ため、Claude 通常運用で手動コマンドを叩く必要はありません。Codex CLI が使える環境では、同じ `spotter install` が Codex native hooks も登録し、`.spotter/tool-db.codex.json` も同期 seed します。これにより初回 Codex セッションから catalog を読めます。以降の Codex `SessionStart` hook は `spotter db refresh --host-agent codex` を bg 起動して `.spotter/tool-db.codex.json` を更新します。Claude catalog には書き込みません。Claude discovery は `claude mcp list` と Claude skills / sub-agents、Codex discovery は `codex mcp list/get` と Codex skills を読むため、両 host の利用可能ツール差分を別 DB として保持できます。各 MCP サーバーの `tools/list` は JSON-RPC で取得 (HTTP / SSE / stdio transport 対応)、スキルとサブエージェントは frontmatter から直接抽出、claude.ai baseline (OAuth proxy 経由の Gmail / Calendar / Drive 25 件) は Claude 側でのみ `claude mcp list` に該当サーバーが存在する環境で注入されます。**手書きでツールリストを管理する必要はありません**。
|
|
111
111
|
|
|
112
112
|
## Throughline との関係
|
|
113
113
|
|
|
@@ -178,8 +178,8 @@ Codex CLI auditor の子プロセスは、hook 判定を安く速く保つため
|
|
|
178
178
|
|
|
179
179
|
## 既知の制約
|
|
180
180
|
|
|
181
|
-
- Stop hook は
|
|
182
|
-
-
|
|
181
|
+
- 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 回避も兼ねる)
|
|
182
|
+
- pending ファイルは Claude / Codex が同じパス (`.spotter/pending/`) を共有します。host-neutral 設計です
|
|
183
183
|
- **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
184
|
|
|
185
185
|
<details>
|
package/README.md
CHANGED
|
@@ -75,8 +75,8 @@ flowchart TD
|
|
|
75
75
|
BA --> SH[Stop hook<br/>Spotter re-audits answer + tools used]
|
|
76
76
|
SH --> DEC{Missed<br/>tool?}
|
|
77
77
|
DEC -->|No| DONE([Done])
|
|
78
|
-
DEC -->|Yes| SB[
|
|
79
|
-
SB -->
|
|
78
|
+
DEC -->|Yes| SB[Queue finding to .spotter/pending/<br/>v1.4.8 deferred delivery]
|
|
79
|
+
SB --> NEXT([Surfaces as additionalContext<br/>on next UserPromptSubmit])
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
### Catalog discovery
|
|
@@ -107,7 +107,7 @@ flowchart LR
|
|
|
107
107
|
|
|
108
108
|
The audited catalog is host-local: Claude uses `<project>/.spotter/tool-db.json`, while Codex uses `<project>/.spotter/tool-db.codex.json`. **The daemon audits against the Claude local DB only**, and Codex native hooks read the Codex local DB. Global description caches are host-specific too: Claude uses `~/.spotter/tool-db.json`, while Codex uses `~/.spotter/tool-db.codex.json`. They are shared only across projects for the same host and are never audit sources. Each host-local DB matches that host's **current** discovery snapshot for the project (stale entries are pruned on refresh), so tools from another project or another host cannot overwrite this session's audit catalog.
|
|
109
109
|
|
|
110
|
-
**`spotter install` seeds the Claude catalog automatically, and the SessionStart hook runs a background `spotter db refresh` on every Claude Code session start** — so you don't need to invoke Claude catalog commands by hand. When Codex CLI is available, the same `spotter install` registers Codex native hooks
|
|
110
|
+
**`spotter install` seeds the Claude catalog automatically, and the SessionStart hook runs a background `spotter db refresh` on every Claude Code session start** — so you don't need to invoke Claude catalog commands by hand. When Codex CLI is available, the same `spotter install` registers Codex native hooks and seeds `.spotter/tool-db.codex.json` synchronously, so the first Codex session has a catalog too. Later Codex `SessionStart` hooks start `spotter db refresh --host-agent codex` in the background, updating `.spotter/tool-db.codex.json` without touching the Claude catalog. Claude discovery reads `claude mcp list` plus Claude skills / sub-agents; Codex discovery reads `codex mcp list/get` plus Codex skills. Each MCP server's `tools/list` is fetched via JSON-RPC (HTTP / SSE / stdio transports supported); skill and sub-agent metadata comes straight from frontmatter; the claude.ai baseline (25 hand-curated entries for Gmail / Calendar / Drive over OAuth proxy) is injected only for Claude when `claude mcp list` confirms the server is present. **You never have to maintain the tool list by hand.**
|
|
111
111
|
|
|
112
112
|
## Spotter and Throughline
|
|
113
113
|
|
|
@@ -182,7 +182,7 @@ those values for smoke tests or controlled experiments.
|
|
|
182
182
|
## Known limitations
|
|
183
183
|
|
|
184
184
|
- 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
|
-
-
|
|
185
|
+
- `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
186
|
- **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
187
|
|
|
188
188
|
<details>
|
package/bin/spotter.mjs
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import {
|
|
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,6 @@ 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';
|
|
31
38
|
|
|
32
39
|
const CODEX_HOOK_USAGE = `spotter codex-hook — Codex native hook adapter
|
|
33
40
|
|
|
@@ -114,7 +121,7 @@ export async function runCodexUserPromptSubmitHook({
|
|
|
114
121
|
const startedAt = Date.now();
|
|
115
122
|
|
|
116
123
|
const prompt = requireString(input, 'prompt');
|
|
117
|
-
const contexts = await
|
|
124
|
+
const contexts = await drainPendingContexts({ projectRoot, sessionId: codexSessionId(input) });
|
|
118
125
|
if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) {
|
|
119
126
|
await recordCodexHookEventSafe(recordHookEventFn, {
|
|
120
127
|
projectRoot,
|
|
@@ -213,7 +220,7 @@ export async function runCodexStopHook({
|
|
|
213
220
|
} catch (err) {
|
|
214
221
|
const errorText = formatCodexHookBackendError(err);
|
|
215
222
|
writeError(`${errorText}\n`);
|
|
216
|
-
await
|
|
223
|
+
await appendPendingContext({
|
|
217
224
|
projectRoot,
|
|
218
225
|
sessionId: codexSessionId(input),
|
|
219
226
|
text: errorText,
|
|
@@ -248,7 +255,7 @@ export async function runCodexStopHook({
|
|
|
248
255
|
return;
|
|
249
256
|
}
|
|
250
257
|
|
|
251
|
-
await
|
|
258
|
+
await appendPendingContext({
|
|
252
259
|
projectRoot,
|
|
253
260
|
sessionId: codexSessionId(input),
|
|
254
261
|
text: formatTransparentBlockReason(legacyResultFromJudgment(judgment).missing_tools),
|
|
@@ -450,60 +457,17 @@ function codexSessionId(payload) {
|
|
|
450
457
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
451
458
|
}
|
|
452
459
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
}
|
|
460
|
+
// Phase B (hook parity, 2026-05-08): pending-context helpers were moved to
|
|
461
|
+
// `src/hooks/pending-context.mjs` and the on-disk path migrated from
|
|
462
|
+
// `.spotter/codex-pending/` to host-neutral `.spotter/pending/`. The Claude Stop hook
|
|
463
|
+
// now writes to the same queue.
|
|
497
464
|
|
|
465
|
+
// Phase D (hook parity, 2026-05-08): Codex hook events now go through the host-neutral
|
|
466
|
+
// `appendHookEvent` so Claude / Codex events live in the same `.spotter/hook-events.jsonl`.
|
|
467
|
+
// Kept as an internal wrapper so existing call sites (and the `recordHookEventFn` DI knob
|
|
468
|
+
// in tests) can stay on the same shape.
|
|
498
469
|
async function appendCodexHookEvent({ projectRoot, event }) {
|
|
499
|
-
|
|
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');
|
|
470
|
+
await appendHookEvent({ projectRoot, host: 'codex', event });
|
|
507
471
|
}
|
|
508
472
|
|
|
509
473
|
async function recordCodexHookEventSafe(recordHookEventFn, input, writeError) {
|
|
@@ -514,17 +478,24 @@ async function recordCodexHookEventSafe(recordHookEventFn, input, writeError) {
|
|
|
514
478
|
}
|
|
515
479
|
}
|
|
516
480
|
|
|
481
|
+
// Phase D (hook parity, 2026-05-08): Codex `--project` diagnostics now read the host-neutral
|
|
482
|
+
// `<projectRoot>/.spotter/hook-events.jsonl` and filter to `host:"codex"` so the existing
|
|
483
|
+
// `codex-hook diagnostics` shape (counts of just Codex events) stays intact.
|
|
517
484
|
export async function summarizeCodexHookEvents({ projectRoot, readFileFn = readFile } = {}) {
|
|
518
485
|
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
519
486
|
throw new TypeError('summarizeCodexHookEvents: projectRoot must be a non-empty string');
|
|
520
487
|
}
|
|
488
|
+
const full = await summarizeHookEvents({ projectRoot, readFileFn });
|
|
489
|
+
// Re-aggregate with a Codex-only filter so the legacy diagnostics caller doesn't see Claude
|
|
490
|
+
// entries pulled in from the unified file. We re-read the JSONL ourselves to keep counts
|
|
491
|
+
// exact (summarizeHookEvents already iterated, but it folded Claude entries in).
|
|
521
492
|
const summary = {
|
|
522
|
-
schema: 'spotter.
|
|
493
|
+
schema: 'spotter.hook_events_summary.v1',
|
|
523
494
|
projectRoot,
|
|
524
|
-
logPath:
|
|
525
|
-
exists:
|
|
495
|
+
logPath: hookEventsPath(projectRoot),
|
|
496
|
+
exists: full.exists,
|
|
526
497
|
events: 0,
|
|
527
|
-
parseErrors:
|
|
498
|
+
parseErrors: full.parseErrors,
|
|
528
499
|
byHook: {},
|
|
529
500
|
byStatus: {},
|
|
530
501
|
byBackend: {},
|
|
@@ -532,19 +503,19 @@ export async function summarizeCodexHookEvents({ projectRoot, readFileFn = readF
|
|
|
532
503
|
maxDurationMs: 0,
|
|
533
504
|
recent: [],
|
|
534
505
|
};
|
|
506
|
+
if (!summary.exists) return summary;
|
|
535
507
|
let totalDurationMs = 0;
|
|
536
508
|
try {
|
|
537
509
|
const raw = await readFileFn(summary.logPath, 'utf8');
|
|
538
|
-
summary.exists = true;
|
|
539
510
|
for (const line of raw.split(/\r?\n/)) {
|
|
540
511
|
if (!line.trim()) continue;
|
|
541
512
|
let event;
|
|
542
513
|
try {
|
|
543
514
|
event = JSON.parse(line);
|
|
544
515
|
} catch {
|
|
545
|
-
summary.parseErrors += 1;
|
|
546
516
|
continue;
|
|
547
517
|
}
|
|
518
|
+
if (event.host !== 'codex') continue;
|
|
548
519
|
summary.events += 1;
|
|
549
520
|
incrementCounter(summary.byHook, event.hook ?? 'unknown');
|
|
550
521
|
incrementCounter(summary.byStatus, event.status ?? 'unknown');
|
|
@@ -577,10 +548,6 @@ function compactCodexHookEvent(event) {
|
|
|
577
548
|
};
|
|
578
549
|
}
|
|
579
550
|
|
|
580
|
-
function codexHookEventsPath(projectRoot) {
|
|
581
|
-
return join(projectRoot, '.spotter', CODEX_HOOK_EVENTS_FILE);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
551
|
function incrementCounter(counter, key) {
|
|
585
552
|
counter[key] = (counter[key] ?? 0) + 1;
|
|
586
553
|
}
|
|
@@ -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(
|
|
38
|
+
writeOutput(JSON.stringify(merged, null, 2) + '\n');
|
|
29
39
|
return;
|
|
30
40
|
}
|
|
31
|
-
writeOutput(formatDaemonLogSummary(
|
|
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;
|
package/src/cli/install.mjs
CHANGED
|
@@ -120,9 +120,11 @@ export async function runInstall({
|
|
|
120
120
|
console.log(`wrote ${settingsPath}`);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
let codexHooksRegistered = false;
|
|
123
124
|
if (target === 'project' && !skipCodexHooks) {
|
|
124
125
|
if (codexCliPresentFn()) {
|
|
125
126
|
const result = await installCodexHooksFn();
|
|
127
|
+
codexHooksRegistered = true;
|
|
126
128
|
console.log(' Codex hooks registered');
|
|
127
129
|
console.log(` Codex hooks: ${result.hooksPath}`);
|
|
128
130
|
} else {
|
|
@@ -140,23 +142,33 @@ export async function runInstall({
|
|
|
140
142
|
console.log('\ndiscovering MCP servers, skills, and sub-agents...');
|
|
141
143
|
const log = (msg) => process.stderr.write(` ${msg}\n`);
|
|
142
144
|
try {
|
|
143
|
-
const
|
|
144
|
-
console.log(` ${
|
|
145
|
-
console.log(` local DB: ${localDbPath(cwd, 'claude')}`);
|
|
146
|
-
console.log(` global DB: ${globalDbPath('claude')}`);
|
|
145
|
+
const claudeResolved = await refreshFn({ projectRoot: cwd, hostAgent: 'claude', logFn: log });
|
|
146
|
+
console.log(` ${claudeResolved.size} Claude tool(s) resolved`);
|
|
147
|
+
console.log(` Claude local DB: ${localDbPath(cwd, 'claude')}`);
|
|
148
|
+
console.log(` Claude global DB: ${globalDbPath('claude')}`);
|
|
149
|
+
if (codexHooksRegistered) {
|
|
150
|
+
const codexResolved = await refreshFn({ projectRoot: cwd, hostAgent: 'codex', logFn: log });
|
|
151
|
+
console.log(` ${codexResolved.size} Codex tool(s) resolved`);
|
|
152
|
+
console.log(` Codex local DB: ${localDbPath(cwd, 'codex')}`);
|
|
153
|
+
console.log(` Codex global DB: ${globalDbPath('codex')}`);
|
|
154
|
+
}
|
|
147
155
|
} catch (err) {
|
|
148
156
|
// §0: throw (fallback 禁止). But surface the recovery path so the user isn't
|
|
149
157
|
// left with "hooks registered, tool-db missing" and no clue what to run.
|
|
150
158
|
process.stderr.write(`\nspotter install: tool-db seeding failed.\n`);
|
|
151
159
|
process.stderr.write(` hooks are registered but tool-db is not ready.\n`);
|
|
152
|
-
process.stderr.write(` recover with: spotter db refresh\n`);
|
|
160
|
+
process.stderr.write(` recover with: spotter db refresh and, for Codex, spotter db refresh --host-agent codex\n`);
|
|
153
161
|
throw err;
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
console.log('\nnext steps:');
|
|
158
166
|
console.log(' reload Claude Code (or open a new session) to activate Spotter');
|
|
159
|
-
|
|
167
|
+
if (codexHooksRegistered) {
|
|
168
|
+
console.log(' open a new Codex session to activate Codex hooks');
|
|
169
|
+
} else if (target === 'project' && !skipCodexHooks) {
|
|
170
|
+
console.log(' Codex hooks are not active: rerun `spotter install` where `codex --version` succeeds');
|
|
171
|
+
}
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
async function exists(path) {
|
|
@@ -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: '
|
|
180
|
-
mode: '
|
|
181
|
-
compatibility: '
|
|
182
|
-
reason: '
|
|
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(
|