From 7231a8a9a3f56c6fb0e75cac2fc976d25ea9b4e7 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:56:14 +0000 Subject: [PATCH 1/7] Accept UTF-8 BOM in hook stdin JSON --- src/hooks/lib.mjs | 4 +++- test/hooks.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/hooks/lib.mjs b/src/hooks/lib.mjs index 322e92f..4838e44 100644 --- a/src/hooks/lib.mjs +++ b/src/hooks/lib.mjs @@ -104,7 +104,9 @@ export async function readStdinJson() { throw err; } try { - return JSON.parse(raw); + // Windows Cursor may prefix hook JSON with a UTF-8 BOM. Node decodes it + // to U+FEFF, which JSON.parse rejects even though the envelope is valid. + return JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw); } catch (cause) { const err = new Error(`hook stdin is not valid JSON: ${cause.message}`); err.exitCode = 2; diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 99790c6..9dda3b0 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -24,6 +24,28 @@ const formatTransparentContext = (entries) => projectParentAdvice(entries.map((e const formatTransparentBlockReason = formatTransparentContext; const formatSpotterWarning = ({ code }) => projectBackendFailure(code).systemMessage; const TEST_EVALUATION_STORE = { recordTurn() {}, close() {} }; + +test('hook stdin accepts a leading UTF-8 BOM but still rejects malformed JSON', () => { + const bin = fileURLToPath(new URL('../bin/spotter.mjs', import.meta.url)); + const env = { ...process.env }; + delete env.SPOTTER_PARENT_PID; + delete env.SPOTTER_BACKEND; + delete env.SPOTTER_CHILD_BACKEND; + const run = (input) => spawnSync(process.execPath, [bin, 'cursor-hook', 'session-start'], { + input, + encoding: 'utf8', + env, + }); + const valid = run(Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(JSON.stringify({ cwd: '/does/not/exist', conversation_id: 'test' })), + ])); + assert.equal(valid.status, 0, valid.stderr); + assert.equal(valid.stderr, ''); + const invalid = run(Buffer.from([0xef, 0xbb, 0xbf, 0x7b])); + assert.equal(invalid.status, 2); + assert.match(invalid.stderr, /hook stdin is not valid JSON/); +}); async function runUserPrompt(options = {}) { return runUserPromptImpl({ createEvaluationStoreFn: () => TEST_EVALUATION_STORE, From 5222c5a7e4251908991fca7c9eae9d12b6a38652 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:56:14 +0000 Subject: [PATCH 2/7] Allow Codex auditor in non-Git projects --- docs/02_spotter-claude-contract.md | 4 ++++ src/core/codex-cli-backend.mjs | 3 +++ test/codex-cli-backend.test.mjs | 1 + 3 files changed, 8 insertions(+) diff --git a/docs/02_spotter-claude-contract.md b/docs/02_spotter-claude-contract.md index 9b53cf6..5517f51 100644 --- a/docs/02_spotter-claude-contract.md +++ b/docs/02_spotter-claude-contract.md @@ -106,6 +106,8 @@ factory adapter向けの単一判定`compatibility_status`を追加する。値 All hooks read one JSON object from stdin unless `isChildCall()` finds a non-empty `SPOTTER_PARENT_PID`, `SPOTTER_BACKEND`, or `SPOTTER_CHILD_BACKEND`. Invalid or empty stdin is an unexpected hook failure. +The shared stdin reader accepts one leading UTF-8 BOM, which Windows Cursor can add to hook JSON. +The JSON object and envelope validation rules remain unchanged. Codex native hooks use Codex hook payloads, not Claude hook JSON. The current Codex adapter installs user-level `~/.codex/hooks.json` entries for `SessionStart`, @@ -129,6 +131,8 @@ answer and does not queue model-facing text for a later turn. Findings remain st backend failures are reported with an allow-listed fixed `systemMessage`, fixed stderr, and a structured Hook event. Neither path may carry auditor prose or provider stdout / stderr into model context. Codex hook auditor calls prefer Jev when configured, otherwise use the Codex production model policy, with a 20s timeout. +The read-only Codex auditor uses `--skip-git-repo-check` because an installed Spotter project +can be a non-Git directory; Codex's workspace trust gate must not prevent that audit. Short `Stop` final responses with no used tools are skipped to avoid duplicate post-answer latency. When a Codex surface has no persisted transcript and sends a missing, `null`, or empty diff --git a/src/core/codex-cli-backend.mjs b/src/core/codex-cli-backend.mjs index 2c1e117..d75bc12 100644 --- a/src/core/codex-cli-backend.mjs +++ b/src/core/codex-cli-backend.mjs @@ -289,6 +289,9 @@ export function buildCodexExecArgs({ schemaPath, lastMessagePath, projectRoot, m '--ephemeral', '--ignore-user-config', '--ignore-rules', + // Spotter can be installed in a non-Git project; the auditor is read-only + // and must not inherit Codex's interactive workspace trust gate. + '--skip-git-repo-check', '--sandbox', 'read-only', '--cd', diff --git a/test/codex-cli-backend.test.mjs b/test/codex-cli-backend.test.mjs index 6b35828..95e8823 100644 --- a/test/codex-cli-backend.test.mjs +++ b/test/codex-cli-backend.test.mjs @@ -191,6 +191,7 @@ test('buildCodexExecArgs: pins schema, last-message, read-only sandbox, and stdi '--ephemeral', '--ignore-user-config', '--ignore-rules', + '--skip-git-repo-check', '--sandbox', 'read-only', '--cd', From afac88c5f7e84862650c654ff3f4c81367698cd0 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:58:06 +0000 Subject: [PATCH 3/7] Allow Node runtime warnings in BOM hook test --- test/hooks.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 9dda3b0..2497565 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -41,7 +41,7 @@ test('hook stdin accepts a leading UTF-8 BOM but still rejects malformed JSON', Buffer.from(JSON.stringify({ cwd: '/does/not/exist', conversation_id: 'test' })), ])); assert.equal(valid.status, 0, valid.stderr); - assert.equal(valid.stderr, ''); + assert.doesNotMatch(valid.stderr, /hook stdin is not valid JSON/); const invalid = run(Buffer.from([0xef, 0xbb, 0xbf, 0x7b])); assert.equal(invalid.status, 2); assert.match(invalid.stderr, /hook stdin is not valid JSON/); From 222a5002d837f1b4d949fc7259398244b619925f Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:25:13 +0000 Subject: [PATCH 4/7] Remove retired WSL2 dashboard device --- docs/11_dashboard-operations.md | 29 +++-------------------------- ops/dashboard/hub-config.json | 2 +- test/dashboard-cli.test.mjs | 2 +- test/dashboard-ops.test.mjs | 2 +- 4 files changed, 6 insertions(+), 29 deletions(-) diff --git a/docs/11_dashboard-operations.md b/docs/11_dashboard-operations.md index 0e2e59f..b4f4ffb 100644 --- a/docs/11_dashboard-operations.md +++ b/docs/11_dashboard-operations.md @@ -5,9 +5,8 @@ ## 固定構成 -各端末のdevice serverはloopbackだけで待ち受ける。main-server、Mac、FOX WSL2は -`127.0.0.1:53940`、FOX Windows nativeはWSL2 localhost relayとの衝突を避けて -`127.0.0.1:53944`を使う。main-serverのhubは +各端末のdevice serverはloopbackだけで待ち受ける。main-serverとMacは +`127.0.0.1:53940`、FOX Windows nativeは`127.0.0.1:53944`を使う。main-serverのhubは Docker Caddyから到達できる`172.18.0.1:53940`で待ち受ける。評価DBは各端末の `~/.spotter/evaluation.db`をその場で読み、端末外へ複製しない。 @@ -15,7 +14,6 @@ Docker Caddyから到達できる`172.18.0.1:53940`で待ち受ける。評価DB |---|---|---| | main-server Ubuntu | `main-server` | `127.0.0.1:53940` | | Mac | `mac` | `127.0.0.1:53941` | -| FOX WSL2 | `fox-wsl` | `127.0.0.1:53942` | | FOX Windows native | `fox-windows` | `127.0.0.1:53943` | hub設定の正本は`ops/dashboard/hub-config.json`である。hubは一覧request時に各upstreamの @@ -60,27 +58,6 @@ curl --fail http://172.18.0.1:53940/ device envにも同じPATH行を置く。値は各端末で実測したnpm binを使い、別の起動経路へfallbackしない。 -## FOX WSL2 - -device unitとtunnel unitを`~/.config/systemd/user/`へ配置する。device env: - -```ini -SPOTTER_DEVICE_ID=fox-wsl -SPOTTER_DEVICE_NAME=FOX-WSL2 -``` - -tunnel env: - -```ini -SPOTTER_REMOTE_FORWARD=127.0.0.1:53942:127.0.0.1:53940 -SPOTTER_TUNNEL_TARGET=main-server -``` - -```sh -systemctl --user daemon-reload -systemctl --user enable --now spotter-dashboard-device.service spotter-dashboard-tunnel.service -``` - ## FOX Windows native 同梱された`ops/dashboard/windows/`のPowerShellを固定pathへ配置する。npm global版から @@ -163,6 +140,6 @@ reverse tunnelを確認する。hubや別端末を再起動する必要はない 公開受入: 1. 未認証`https://spotter.kitepon.dev/`がCloudflare Accessへredirectされる。 -2. 認証後の`/`が4端末を表示する。 +2. 認証後の`/`が3端末を表示する。 3. online端末のoverview、project/tool内訳、非採用case、case詳細を表示できる。 4. 1端末を停止しても一覧と他端末が表示でき、停止端末だけoffline/502になる。 diff --git a/ops/dashboard/hub-config.json b/ops/dashboard/hub-config.json index dd08664..fffac37 100644 --- a/ops/dashboard/hub-config.json +++ b/ops/dashboard/hub-config.json @@ -1 +1 @@ -{"devices":[{"id":"main-server","name":"main-server","upstream":"http://127.0.0.1:53940"},{"id":"mac","name":"Mac","upstream":"http://127.0.0.1:53941"},{"id":"fox-wsl","name":"FOX WSL2","upstream":"http://127.0.0.1:53942"},{"id":"fox-windows","name":"FOX Windows native","upstream":"http://127.0.0.1:53943"}]} +{"devices":[{"id":"main-server","name":"main-server","upstream":"http://127.0.0.1:53940"},{"id":"mac","name":"Mac","upstream":"http://127.0.0.1:53941"},{"id":"fox-windows","name":"FOX Windows native","upstream":"http://127.0.0.1:53943"}]} diff --git a/test/dashboard-cli.test.mjs b/test/dashboard-cli.test.mjs index 4b8f1f4..b3fa836 100644 --- a/test/dashboard-cli.test.mjs +++ b/test/dashboard-cli.test.mjs @@ -34,7 +34,7 @@ test('hub command reads the static device map once and starts the hub server', a let factoryOptions; const config = { devices: [ { id: 'mac', name: 'Mac', upstream: 'http://127.0.0.1:53941' }, - { id: 'fox-wsl', name: 'FOX WSL2', upstream: 'http://127.0.0.1:53942' }, + { id: 'fox-windows', name: 'FOX Windows native', upstream: 'http://127.0.0.1:53943' }, ] }; await runDashboardCommand({ argv: ['hub', '--config', 'hub.json', '--host', '172.18.0.1'], diff --git a/test/dashboard-ops.test.mjs b/test/dashboard-ops.test.mjs index d40027d..60e5b9b 100644 --- a/test/dashboard-ops.test.mjs +++ b/test/dashboard-ops.test.mjs @@ -10,7 +10,7 @@ test('Mac dashboard LaunchAgent exposes Homebrew Node to the spotter env shebang assert.match(plist, /PATH<\/key>\/opt\/homebrew\/bin:/); }); -test('Windows native device avoids the WSL2 localhost relay port', async () => { +test('Windows native device and tunnel use their assigned ports', async () => { const device = await read('ops/dashboard/windows/spotter-dashboard-device.ps1'); const tunnel = await read('ops/dashboard/windows/spotter-dashboard-tunnel.ps1'); assert.match(device, /--port 53944/); From 6e59754d5db9594b0e7c937abb89ace20f1dc8d4 Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:25:20 +0000 Subject: [PATCH 5/7] Support Grok native hooks and host-local catalog --- AGENTS.md | 4 +- README.ja.md | 6 +- README.md | 6 +- bin/spotter.mjs | 6 + docs/01_catalog-design.md | 5 + docs/02_spotter-claude-contract.md | 16 +- docs/open-issues.md | 12 +- src/cli/db-cmd.mjs | 6 +- src/cli/doctor.mjs | 20 ++- src/cli/grok-hook-cmd.mjs | 239 +++++++++++++++++++++++++++++ src/cli/install.mjs | 29 +++- src/core/auditor-backend.mjs | 6 +- src/core/hook-event-log.mjs | 4 +- src/core/host-agent.mjs | 3 +- src/hooks/lib.mjs | 7 +- src/host/adapters.mjs | 11 +- src/tool-db/investigate-grok.mjs | 48 ++++++ src/tool-db/refresh.mjs | 3 +- test/grok-host.test.mjs | 173 +++++++++++++++++++++ test/hook-event-log.test.mjs | 2 +- test/install.test.mjs | 28 +++- 21 files changed, 598 insertions(+), 36 deletions(-) create mode 100644 src/cli/grok-hook-cmd.mjs create mode 100644 src/tool-db/investigate-grok.mjs create mode 100644 test/grok-host.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 22ed016..ade1bd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ agent-neutral coreのadapterであり、detector、reporting、stateをhost別 path正規化と実行体探索は`paths.mjs`に置く。 - host依存の決定点は`src/host/adapters.mjs`だけが所有する。host固有実装は専用moduleへ閉じ、 業務ロジックへ`process.platform`やhost別分岐を散らさない。 -- Claude、Codex、Cursorのtool DBはhost-localかつ別fileで所有し、一方のrefreshで他方を +- Claude、Codex、Cursor、Grokのtool DBはhost-localかつ別fileで所有し、一方のrefreshで他方を pruneまたはoverwriteしない。global DBはdescription cacheだけで、audit入力へ混ぜない。 ### 再帰安全 @@ -87,7 +87,7 @@ daemon lifecycleはapp-level heartbeatとUserPromptSubmit auto-resurrectを使 - Claude呼出しはsession-scoped、preamble-once、schema失敗時のsession renewを使う。 - 隔離workdir `~/.spotter/workdir/`へ`CLAUDE.md`を置かない。 - Claudeは`.spotter/tool-db.json`、Codexは`.spotter/tool-db.codex.json`、Cursorは - `.spotter/tool-db.cursor.json`だけを監査入力にする。 + `.spotter/tool-db.cursor.json`、Grokは`.spotter/tool-db.grok.json`だけを監査入力にする。 公開CLI、hook / daemon IPC、runtime error store、evaluation、dashboardの現行contractとtest対応表は [docs/02_spotter-claude-contract.md](docs/02_spotter-claude-contract.md)を正とする。未解決事項は diff --git a/README.ja.md b/README.ja.md index c6ca49b..ad66a1b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -68,16 +68,18 @@ cd your-project spotter install ``` -macOS の Homebrew Node 環境では、Codex hook command の Node パスに現在の実体と一致する +macOS の Homebrew Node 環境では、CodexとGrokのhook commandのNodeパスに現在の実体と一致する 安定 symlink (`/opt/homebrew/bin/node`) を使います。 `/opt/homebrew/Cellar/node//...` のような version 固定パスを書かないため、 -Homebrew で Node が更新されても Codex hook が古い Node パスに取り残されません。 +Homebrew で Node が更新されてもhookが古いNodeパスに取り残されません。 `v0.3.0` 以降は**プロジェクト単位の明示的 install** を採用しています (v0.2 までの `postinstall` 自動登録はデーモン増殖の主因だったため撤回)。各プロジェクトの `.claude/settings.json` に hook を登録し、そのプロジェクトでの Claude Code セッションのみで有効になります。 Codex CLI が使える環境では、同じ `spotter install` が user-level の Codex native hooks も登録します。実際に動くプロジェクトは `spotter install` が作る `.spotter/marker.json` で制限されるため、無関係な Codex セッションでは Spotter は起動しません。 Codex 側では現行の `[features].hooks = true` を有効化し、互換のため旧 `codex_hooks` diagnostics output も認識します。 Spotter が所有する Codex handler は現行の同期 command schema で生成します。install / upgrade 後は `/hooks` で review して新しい Codex session を開いてください。`spotter codex-hook diagnostics` は登録と readiness を診断しますが、trust を内部状態から推測しません。 +Grok Buildがある環境では、`spotter install`はGrok native hookも登録し、専用の`.spotter/tool-db.grok.json`を初期化します。入力時と応答後の監査結果は`.spotter/hook-events.jsonl`と評価DBに残ります。Grok 1.0.41は受動hookのstdoutを会話へ渡さないため、findingは親会話には表示されません。登録は`spotter grok-hook diagnostics`で確認し、install後は新しいGrok sessionを開いてください。 + Spotter を upgrade した後、release note で hook 設定変更が案内されている場合は、各 install 済みプロジェクトで `spotter install` を再実行してください。global package update でコード経路は変わりますが、既存 `.claude/settings.json` の timeout 値は自動では書き換わりません。 ```bash diff --git a/README.md b/README.md index 2006d15..8cdf9f2 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,18 @@ cd your-project spotter install ``` -On macOS with Homebrew Node, Codex hook commands use the stable +On macOS with Homebrew Node, Codex and Grok hook commands use the stable `/opt/homebrew/bin/node` symlink when it resolves to the current Node binary, instead of a versioned `/opt/homebrew/Cellar/node//...` path. That keeps -Codex hooks working across Homebrew Node upgrades. +both sets of hooks working across Homebrew Node upgrades. 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. 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. For Codex, install enables the current `[features].hooks = true` flag and still recognizes older `codex_hooks` diagnostics output for compatibility. Installer-owned Codex handlers use the current synchronous command schema. After install or upgrade, review them with `/hooks`, then open a fresh Codex session; `spotter codex-hook diagnostics` reports registration/readiness but does not guess hook trust. +When Grok Build is installed, `spotter install` also registers native Grok hooks and seeds a separate `.spotter/tool-db.grok.json`. Grok prompt and final-response audits write findings to `.spotter/hook-events.jsonl` and the evaluation database. Grok 1.0.41 ignores stdout from passive hooks, so it does not show those findings in the parent conversation. Check registration with `spotter grok-hook diagnostics` and open a new Grok session after install. + 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. ```bash diff --git a/bin/spotter.mjs b/bin/spotter.mjs index cfa84d0..2240b93 100755 --- a/bin/spotter.mjs +++ b/bin/spotter.mjs @@ -9,6 +9,7 @@ import { runStatus } from '../src/cli/status.mjs'; import { runDbList, runDbRefresh, runDbRebuild } from '../src/cli/db-cmd.mjs'; import { runCodexHookCommand } from '../src/cli/codex-hook-cmd.mjs'; import { runCursorHookCommand } from '../src/cli/cursor-hook-cmd.mjs'; +import { runGrokHookCommand } from '../src/cli/grok-hook-cmd.mjs'; import { runAuditorCommand } from '../src/cli/auditor-cmd.mjs'; import { runDiagnosticsCommand } from '../src/cli/diagnostics-cmd.mjs'; import { runEvaluationCommand } from '../src/cli/evaluation-cmd.mjs'; @@ -65,6 +66,8 @@ Usage: (experimental) manage Codex native hooks spotter cursor-hook install|uninstall|diagnostics manage Cursor native catalog-refresh hooks + spotter grok-hook install|uninstall|diagnostics + manage Grok native audit hooks spotter auditor judge --stage STAGE --input FILE (experimental) run primary auditor backend once spotter auditor matrix --stage STAGE --input FILE @@ -127,6 +130,9 @@ async function main() { case 'cursor-hook': await runCursorHookCommand({ argv: rest }); return; + case 'grok-hook': + await runGrokHookCommand({ argv: rest }); + return; case 'auditor': await runAuditorCommand({ argv: rest }); return; diff --git a/docs/01_catalog-design.md b/docs/01_catalog-design.md index 3bb332e..aa97ac0 100644 --- a/docs/01_catalog-design.md +++ b/docs/01_catalog-design.md @@ -165,6 +165,11 @@ logしない。DB自体のJSON/schema違反は | スキル | [investigate-skills.mjs](../src/tool-db/investigate-skills.mjs) | user scope `~/.claude/skills/`、project scope `/.claude/skills/`、有効化プラグインの `skills/` | | サブエージェント | [investigate-agents.mjs](../src/tool-db/investigate-agents.mjs) | user scope `~/.claude/agents/`、project scope `/.claude/agents/`、有効化プラグインの `agents/` | +Grok hostは`tool-db.grok.json`を持ち、[investigate-grok.mjs](../src/tool-db/investigate-grok.mjs)が +`grok inspect --json`の有効な追加skill / agentと`grok mcp list --json`の有効なMCPを取得する。 +標準のbundled / builtin項目は除外し、MCP説明は共通の`tools/list`で取得する。 +GrokのMCP名はhostが表示する`__`形で保存する。 + Codex host の refresh は [investigate-codex.mjs](../src/tool-db/investigate-codex.mjs) で別経路を使う。MCP は `codex mcp list` / `codex mcp get --json` で membership と spawn 情報を取り、同じ JSON-RPC `tools/list` で description を取得する。実行用envは構造化JSONの値だけを使う。表示形式の伏字を実行設定へ混ぜず、JSON不正は明示エラーとする。envの値はMCP子processへの受渡しにだけ使い、catalogやlogへ保存しない。Codex skills は `~/.codex/skills/.system/`、`~/.codex/skills/`、`/.codex/skills/`、および `~/.codex/config.toml` で enabled な plugin cache の `skills/` から frontmatter description を読む。Claude の `.claude` 設定を Codex refresh の代替 source として使わない。 プラグインの有効化判定: user scope `~/.claude/settings.json` と project scope `.claude/settings.local.json` の `enabledPlugins` を両方見て、どちらかで `true` なら有効。`~/.claude/plugins/installed_plugins.json` の `installPath` から実体にアクセスする。 diff --git a/docs/02_spotter-claude-contract.md b/docs/02_spotter-claude-contract.md index 5517f51..753c371 100644 --- a/docs/02_spotter-claude-contract.md +++ b/docs/02_spotter-claude-contract.md @@ -42,9 +42,9 @@ Public CLI: - `spotter install [-y|--yes] [--user] [--auditor-context disabled|throughline] [--throughline-command ] [--throughline-arg ]` - `spotter uninstall [-y|--yes] [--user]` -- `spotter db list [--host-agent claude|codex|automation|cursor]` -- `spotter db refresh [--host-agent claude|codex|automation|cursor]` -- `spotter db rebuild [--host-agent claude|codex|automation|cursor]` +- `spotter db list [--host-agent claude|codex|automation|cursor|grok]` +- `spotter db refresh [--host-agent claude|codex|automation|cursor|grok]` +- `spotter db rebuild [--host-agent claude|codex|automation|cursor|grok]` - `spotter status` - `spotter doctor` - `spotter diagnostics logs [--log-dir ] [--project ] [--json]` @@ -60,6 +60,7 @@ Public CLI: - `spotter dashboard device --id [--name ] [--host ] [--port ] [--db ]` - `spotter dashboard hub --config [--host ] [--port ]` - `spotter codex-hook install [--codex-home ]` (Codex native hooks) +- `spotter grok-hook install|uninstall|diagnostics []` (Grok native hooks) - `spotter codex-hook uninstall [--codex-home ]` - `spotter codex-hook diagnostics [--codex-home ] [--project ]` - `spotter auditor judge --stage --input [...]` (experimental) @@ -145,6 +146,15 @@ Codex `SessionStart` handler timeout is 30 seconds. The hook itself only launche Windows nativeではNode起動とproject discoveryが5秒を超える実測があるため、installerは旧5秒設定を 再install時に30秒へ正規化する。UserPromptSubmit / Stopは従来どおり60秒である。 +Grok native hooksは`~/.grok/hooks/spotter.json`へ`SessionStart`、`UserPromptSubmit`、`Stop`、`SessionEnd`を登録する。 +`GROK_HOME`があればその下へ登録する。GrokのcamelCase envelopeを使い、project markerがない場所と +Spotter子backendのhookを除外する。Claude互換hookへ同じGrokイベントが届いても、`hookEventName`と +`sessionId`で見分けてClaude経路へは渡さない。Grok catalogは`tool-db.grok.json`だけを読み、 +`SessionStart`でrefreshを完了させてから監査へ進む。`Stop`はGrok transcriptの現行turnのtool callと +`lastAssistantMessage`を使う。headless実行で最終応答つき`Stop`が欠けた場合は、`SessionEnd`で +未完の評価turnだけをtranscriptから監査して閉じる。Grok 1.0.41は受動hookのstdoutを無視するため、findingは +`.spotter/hook-events.jsonl`と評価DBに記録し、会話へは注入しない。 + - Claude `SessionStart` - returns without spawning when any child-process variable above is set. - returns without spawning when `agent_id` is present. diff --git a/docs/open-issues.md b/docs/open-issues.md index f1633b5..273ab82 100644 --- a/docs/open-issues.md +++ b/docs/open-issues.md @@ -10,16 +10,16 @@ Spotterの現在の未完事項だけを記録する。 - 解決した項目は本文へ残さず、CHANGELOGまたは完了計画へ移す。 - P0は現在のrollout判断、P1は次の運用窓、P2は既知だが未発生のplatform固有リスク。 -## P1 — Grok親の正式host対応 +## P1 — Grok監査結果の会話内表示 -GrokのcamelCase hook envelopeは現在、副作用前のunsupported no-opとして扱う。 -dotagentsのGrok親配線は完了したが、Spotter自身のhost対応は未実装である。 +Grok 1.0.41のnative `UserPromptSubmit` / `Stop` hookはstdoutを会話へ渡さない。 +SpotterはGrok固有のcatalogを使って監査し、findingを構造eventと評価DBに記録するが、 +入力時の提案をGrokの親会話へ届ける経路は現時点でない。 ### 次の行動 -Grok固有のinstall、hook envelope、catalog refresh、diagnosticsの契約をこのrepoで決める。 -focused testと実Grok session受入を通し、Spotterのreleaseと単独installが成立してから、 -dotagentsのhost matrixを`unsupported`から更新する。 +Grokに受動hookから会話へ安全に情報を渡す公式機能が追加された時、 +固定文のparent-output projectorを接続する。追加まではGrokの表示機能を対応済みと数えない。 ## P1 — v1.5.4以降のPrimary auditor SLO判定 diff --git a/src/cli/db-cmd.mjs b/src/cli/db-cmd.mjs index fe047c2..766c270 100644 --- a/src/cli/db-cmd.mjs +++ b/src/cli/db-cmd.mjs @@ -14,9 +14,9 @@ import { writeFile } from 'node:fs/promises'; const DB_USAGE = `spotter db — manage the host-specific tool-db Usage: - spotter db list [--host-agent claude|codex|automation|cursor] - spotter db refresh [--host-agent claude|codex|automation|cursor] - spotter db rebuild [--host-agent claude|codex|automation|cursor] + spotter db list [--host-agent claude|codex|automation|cursor|grok] + spotter db refresh [--host-agent claude|codex|automation|cursor|grok] + spotter db rebuild [--host-agent claude|codex|automation|cursor|grok] `; function requireProjectRoot() { diff --git a/src/cli/doctor.mjs b/src/cli/doctor.mjs index 26958ab..75524b2 100644 --- a/src/cli/doctor.mjs +++ b/src/cli/doctor.mjs @@ -9,6 +9,7 @@ import { resolveJevApiKey, JEV_MODEL } from '../core/jev-backend.mjs'; import { loadDb, globalDbPath, localDbPath } from '../tool-db/loader.mjs'; import { findSpotterMarker } from '../hooks/lib.mjs'; import { codexHookDiagnostics } from './codex-hook-cmd.mjs'; +import { grokHookDiagnostics, isGrokHomePresent } from './grok-hook-cmd.mjs'; import { buildWindowsCompatibleInvocation, execFileWindowsSafe } from '../platform/spawn.mjs'; const execFileP = promisify(execFile); @@ -75,6 +76,17 @@ export async function runDoctor() { warnings += 1; } + if (isGrokHomePresent()) { + try { + const grokHooks = await grokHookDiagnostics(); + mark(grokHooks.installed, `grok native hooks: ${grokHooks.installed ? 'installed' : 'not installed'}`); + if (!grokHooks.installed) warnings += 1; + } catch (err) { + mark(false, 'grok native hooks', err.message); + warnings += 1; + } + } + if (projectRoot) { const auditorContext = await inspectAuditorContextConfiguration({ projectRoot }); mark(auditorContext.ok, `evaluation context: ${auditorContext.mode}`, auditorContext.detail); @@ -83,7 +95,7 @@ export async function runDoctor() { // tool-db (host-specific global caches). Since v1.2.0 these are not part of // audit input; each host audits its project-local DB only. Empty caches are fine. - for (const hostAgent of ['claude', 'codex']) { + for (const hostAgent of ['claude', 'codex', ...(isGrokHomePresent() ? ['grok'] : [])]) { try { const path = globalDbPath(hostAgent); const global = await loadDb(path); @@ -105,6 +117,12 @@ export async function runDoctor() { const codexDb = await checkLocalAuditDb({ projectRoot, hostAgent: 'codex' }); mark(codexDb.ok, `codex local audit DB: ${codexDb.count} tools at ${codexDb.path}`, codexDb.detail); if (!codexDb.ok) warnings += 1; + + if (isGrokHomePresent()) { + const grokDb = await checkLocalAuditDb({ projectRoot, hostAgent: 'grok' }); + mark(grokDb.ok, `grok local audit DB: ${grokDb.count} tools at ${grokDb.path}`, grokDb.detail); + if (!grokDb.ok) warnings += 1; + } } console.log(''); diff --git a/src/cli/grok-hook-cmd.mjs b/src/cli/grok-hook-cmd.mjs new file mode 100644 index 0000000..6c490e3 --- /dev/null +++ b/src/cli/grok-hook-cmd.mjs @@ -0,0 +1,239 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import { createAuditorBackend } from '../core/auditor-backend.mjs'; +import { createEvaluationStore } from '../core/evaluation-store.mjs'; +import { appendHookEventSafe } from '../core/hook-event-log.mjs'; +import { projectBackendFailure, projectToolIds } from '../hooks/parent-output-projector.mjs'; +import { findSpotterMarker, isChildCall, readStdinJson, requireString } from '../hooks/lib.mjs'; +import { readLocal, refresh } from '../tool-db/refresh.mjs'; +import { version } from '../version.mjs'; +import { resolveCodexHookNodePath } from './codex-hook-cmd.mjs'; + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const SPOTTER_BIN = join(PACKAGE_ROOT, 'bin', 'spotter.mjs'); +const EVENTS = Object.freeze({ + SessionStart: ['session_start', 60], + UserPromptSubmit: ['user_prompt_submit', 60], + Stop: ['stop', 60], + SessionEnd: ['session_end', 60], +}); + +function defaultGrokHome() { return process.env.GROK_HOME || join(homedir(), '.grok'); } +function hooksPath(grokHome) { return join(grokHome, 'hooks', 'spotter.json'); } +function hookCommand(nodePath, event) { + const quote = (s) => `"${String(s).replace(/(["\\$`])/g, '\\$1')}"`; + return `${process.platform === 'win32' ? '& ' : ''}${quote(nodePath)} ${quote(SPOTTER_BIN)} grok-hook ${event}`; +} + +export async function installGrokHooks({ grokHome = defaultGrokHome(), nodePath = resolveCodexHookNodePath() } = {}) { + const path = hooksPath(grokHome); + let current = {}; + try { current = JSON.parse(await readFile(path, 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (current === null || typeof current !== 'object' || Array.isArray(current)) throw new TypeError('invalid Grok hook file'); + const next = structuredClone(current); + next.hooks ??= {}; + for (const [event, [sub, timeout]] of Object.entries(EVENTS)) { + const other = Array.isArray(next.hooks[event]) ? next.hooks[event].flatMap((group) => { + if (!Array.isArray(group?.hooks)) return [group]; + const hooks = group.hooks.filter((hook) => !String(hook?.command ?? '').includes('spotter.mjs" grok-hook ')); + return hooks.length ? [{ ...group, hooks }] : []; + }) : []; + other.push({ hooks: [{ type: 'command', command: hookCommand(nodePath, sub), timeout }] }); + next.hooks[event] = other; + } + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(next, null, 2) + '\n'); + return { hooksPath: path }; +} + +export async function uninstallGrokHooks({ grokHome = defaultGrokHome() } = {}) { + const path = hooksPath(grokHome); + let current; + try { current = JSON.parse(await readFile(path, 'utf8')); } + catch (error) { if (error.code === 'ENOENT') return { hooksPath: path, removed: false }; throw error; } + let removed = false; + for (const event of Object.keys(EVENTS)) { + if (!Array.isArray(current?.hooks?.[event])) continue; + current.hooks[event] = current.hooks[event].flatMap((group) => { + if (!Array.isArray(group?.hooks)) return [group]; + const hooks = group.hooks.filter((hook) => { + const owned = String(hook?.command ?? '').includes('spotter.mjs" grok-hook '); + if (owned) removed = true; + return !owned; + }); + return hooks.length ? [{ ...group, hooks }] : []; + }); + if (!current.hooks[event].length) delete current.hooks[event]; + } + if (removed) await writeFile(path, JSON.stringify(current, null, 2) + '\n'); + return { hooksPath: path, removed }; +} + +export async function grokHookDiagnostics({ grokHome = defaultGrokHome() } = {}) { + const path = hooksPath(grokHome); + if (!existsSync(path)) return { hooksPath: path, installed: false }; + const file = JSON.parse(await readFile(path, 'utf8')); + return { hooksPath: path, installed: Object.keys(EVENTS).every((event) => + file?.hooks?.[event]?.some((group) => group?.hooks?.some((hook) => + String(hook.command ?? '').includes('spotter.mjs" grok-hook '))) === true) }; +} + +export function isGrokHomePresent(grokHome = defaultGrokHome()) { return existsSync(grokHome); } + +async function record(projectRoot, hook, event, recordFn = appendHookEventSafe) { + await recordFn({ projectRoot, host: 'grok', event: { hook, ...event } }); +} + +function validateGrokInput(input, event) { + return input?.hookEventName === event && typeof input.sessionId === 'string' && input.sessionId.length > 0; +} + +export async function runGrokHook({ + event, readInput = readStdinJson, readLocalFn = readLocal, + createAuditorBackendFn = createAuditorBackend, refreshFn = refresh, + recordFn = appendHookEventSafe, createEvaluationStoreFn = createEvaluationStore, + writeError = (text) => process.stderr.write(text), +} = {}) { + if (isChildCall()) return; + const input = await readInput(); + const projectRoot = findSpotterMarker(input?.cwd); + if (!projectRoot) return; + const expected = Object.keys(EVENTS).find((key) => EVENTS[key][0] === event); + if (!expected || !validateGrokInput(input, event)) return; + const startedAt = Date.now(); + if (expected === 'SessionStart') { + try { + const tools = await refreshFn({ projectRoot, hostAgent: 'grok' }); + await record(projectRoot, expected, { status: 'refreshed', toolCount: tools.size, durationMs: Date.now() - startedAt }, recordFn); + } catch { + writeError('Spotter のGrokカタログ更新に失敗しました。\n'); + await record(projectRoot, expected, { status: 'degraded', code: 'E_CATALOG_REFRESH', durationMs: Date.now() - startedAt }, recordFn); + } + return; + } + if (expected === 'SessionEnd') { + try { + const open = await findOpenGrokEvaluation({ createEvaluationStoreFn, projectRoot, sessionId: input.sessionId }); + if (!open) return; + } catch { + writeError('Spotter のGrok評価記録を確認できませんでした。\n'); + await record(projectRoot, expected, { status: 'degraded', code: 'E_EVALUATION_STORE', durationMs: Date.now() - startedAt }, recordFn); + return; + } + } + if (expected === 'Stop' && (input.stopHookActive === true || typeof input.lastAssistantMessage !== 'string')) return; + const prompt = expected === 'UserPromptSubmit' ? requireString(input, 'prompt') : null; + let usedTools = []; + if (expected === 'Stop' || expected === 'SessionEnd') { + try { + const turn = await readGrokCurrentTurn(input.transcriptPath); + usedTools = turn.usedTools; + if (expected === 'SessionEnd') input.lastAssistantMessage = turn.finalResponse; + } + catch { + writeError('Spotter のGrok応答記録を読めませんでした。\n'); + await record(projectRoot, expected, { status: 'error', code: 'E_TRANSCRIPT', durationMs: Date.now() - startedAt }, recordFn); + return; + } + if (typeof input.lastAssistantMessage !== 'string' || !input.lastAssistantMessage) return; + } + let backend; + let judgment; + try { + const catalog = await readLocalFn({ projectRoot, hostAgent: 'grok' }); + backend = createAuditorBackendFn({ backend: 'auto', catalog, projectRoot, hostAgent: 'grok', timeoutMs: 45_000 }); + judgment = await backend.judge(expected === 'UserPromptSubmit' + ? { stage: 'user_input', userInput: prompt } + : { stage: 'turn_end', finalResponse: input.lastAssistantMessage, usedTools }); + } catch (error) { + const failure = projectBackendFailure(error?.code); + writeError(failure.stderr); + await record(projectRoot, expected, { + status: 'degraded', code: failure.code, durationMs: Date.now() - startedAt, + }, recordFn); + if (prompt !== null) await recordGrokEvaluation({ createEvaluationStoreFn, projectRoot, input, prompt, status: 'error', writeError }); + return; + } + const toolIds = projectToolIds(judgment.findings.map((finding) => finding.toolName)); + await record(projectRoot, expected === 'SessionEnd' ? 'Stop' : expected, { + status: toolIds.length ? 'finding' : 'success', pass: judgment.pass, + missingTools: toolIds, backend: judgment.meta?.backend ?? backend.name, + usedToolCount: usedTools.length, ...(expected === 'SessionEnd' ? { reason: 'session_end_fallback' } : {}), + durationMs: Date.now() - startedAt, + }, recordFn); + if (prompt !== null) { + await recordGrokEvaluation({ createEvaluationStoreFn, projectRoot, input, prompt, + status: 'success', toolIds, backend: judgment.meta?.backend ?? backend.name, + model: judgment.meta?.model ?? null, writeError }); + } else { + await closeGrokEvaluation({ createEvaluationStoreFn, projectRoot, input, usedTools, writeError }); + } +} + +async function findOpenGrokEvaluation({ createEvaluationStoreFn, projectRoot, sessionId }) { + const store = createEvaluationStoreFn(); + try { + return store.database.prepare(`SELECT observation_id FROM evaluation_turns WHERE session_id = ? AND host = 'grok' AND project_path = ? AND completed_at_ms IS NULL ORDER BY recorded_at_ms DESC LIMIT 1`).get(sessionId, projectRoot); + } finally { store.close(); } +} + +async function recordGrokEvaluation({ createEvaluationStoreFn, projectRoot, input, prompt, status, toolIds = [], backend = null, model = null, writeError }) { + try { + const store = createEvaluationStoreFn(); + try { + store.recordTurn({ observationId: randomUUID(), projectPath: projectRoot, host: 'grok', + sessionId: input.sessionId, auditStatus: status, requestText: prompt, + observerContextStatus: 'not_requested', proposedToolIds: toolIds, backend, model, spotterVersion: version }); + } finally { store.close(); } + } catch { writeError('Spotter の評価記録に失敗しました。\n'); } +} + +async function closeGrokEvaluation({ createEvaluationStoreFn, projectRoot, input, usedTools, writeError }) { + try { + const store = createEvaluationStoreFn(); + try { + const row = store.database.prepare(`SELECT observation_id FROM evaluation_turns WHERE session_id = ? AND host = 'grok' AND project_path = ? AND completed_at_ms IS NULL ORDER BY recorded_at_ms DESC LIMIT 1`).get(input.sessionId, projectRoot); + if (row) store.closeTurn({ observationId: row.observation_id, usedToolIds: usedTools, usageStatus: 'complete' }); + } finally { store.close(); } + } catch { writeError('Spotter の評価記録に失敗しました。\n'); } +} + +export async function readGrokCurrentTurn(transcriptPath) { + if (typeof transcriptPath !== 'string' || !transcriptPath) throw new TypeError('Grok transcript path is required'); + const text = await readFile(transcriptPath, 'utf8'); + let tools = []; + let finalResponse = ''; + for (const line of text.split(/\r?\n/u)) { + if (!line) continue; + let row; + row = JSON.parse(line); + const update = row?.params?.update; + if (update?.sessionUpdate === 'user_message_chunk') { tools = []; finalResponse = ''; } + if (update?.sessionUpdate === 'agent_message_chunk' && typeof update.content?.text === 'string') { + finalResponse += update.content.text; + } + if (update?.sessionUpdate !== 'tool_call') continue; + const name = update?._meta?.['x.ai/tool']?.name ?? update?.title; + if (typeof name === 'string' && name) tools.push(name); + } + return { usedTools: [...new Set(tools)], finalResponse }; +} + +export async function readGrokCurrentTurnTools(transcriptPath) { + return (await readGrokCurrentTurn(transcriptPath)).usedTools; +} + +export async function runGrokHookCommand({ argv = process.argv.slice(2) } = {}) { + const [sub, ...rest] = argv; + if (sub === 'install') { process.stdout.write(JSON.stringify(await installGrokHooks({ grokHome: rest[0] })) + '\n'); return; } + if (sub === 'uninstall') { process.stdout.write(JSON.stringify(await uninstallGrokHooks({ grokHome: rest[0] })) + '\n'); return; } + if (sub === 'diagnostics') { process.stdout.write(JSON.stringify(await grokHookDiagnostics({ grokHome: rest[0] })) + '\n'); return; } + if (Object.values(EVENTS).some(([name]) => name === sub)) { await runGrokHook({ event: sub }); return; } + process.stderr.write('usage: spotter grok-hook install|uninstall|diagnostics|session_start|user_prompt_submit|stop|session_end\n'); + process.exit(2); +} diff --git a/src/cli/install.mjs b/src/cli/install.mjs index 230fce0..f4b1179 100644 --- a/src/cli/install.mjs +++ b/src/cli/install.mjs @@ -25,6 +25,7 @@ import { refresh } from '../tool-db/refresh.mjs'; import { localDbPath, globalDbPath } from '../tool-db/loader.mjs'; import { installCodexHooks } from './codex-hook-cmd.mjs'; import { installCursorHooks, isCursorHomePresent } from './cursor-hook-cmd.mjs'; +import { installGrokHooks, isGrokHomePresent } from './grok-hook-cmd.mjs'; import { prepareRuntimeErrorStoreDirectory } from '../core/runtime-error-store.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -59,11 +60,14 @@ export async function runInstall({ skipRefresh = false, skipCodexHooks = skipRefresh, skipCursorHooks = skipRefresh, + skipGrokHooks = skipRefresh, refreshFn = refresh, codexCliPresentFn = isCodexCliPresent, installCodexHooksFn = installCodexHooks, cursorHomePresentFn = isCursorHomePresent, installCursorHooksFn = installCursorHooks, + grokHomePresentFn = isGrokHomePresent, + installGrokHooksFn = installGrokHooks, prepareRuntimeErrorStoreDirectoryFn = prepareRuntimeErrorStoreDirectory, auditorContext, resolveDefaultAuditorContextFn = resolveDefaultAuditorContext, @@ -166,6 +170,18 @@ export async function runInstall({ } } + let grokHooksRegistered = false; + if (target === 'project' && !skipGrokHooks) { + if (grokHomePresentFn()) { + const result = await installGrokHooksFn(); + grokHooksRegistered = true; + console.log(' Grok hooks registered'); + console.log(` Grok hooks: ${result.hooksPath}`); + } else { + console.log(' Grok home not found — Grok hooks not registered'); + } + } + // Seed the tool-db so the first session has something to audit against. // Runs regardless of whether settings.json changed — re-running `spotter install` // on an already-installed project is the canonical way to refresh tool-db drift @@ -192,12 +208,18 @@ export async function runInstall({ console.log(` Cursor local DB: ${localDbPath(cwd, 'cursor')}`); console.log(` Cursor global DB: ${globalDbPath('cursor')}`); } + if (grokHooksRegistered) { + const grokResolved = await refreshFn({ projectRoot: cwd, hostAgent: 'grok', logFn: log }); + console.log(` ${grokResolved.size} Grok tool(s) resolved`); + console.log(` Grok local DB: ${localDbPath(cwd, 'grok')}`); + console.log(` Grok global DB: ${globalDbPath('grok')}`); + } } catch (err) { // §0: throw (fallback 禁止). But surface the recovery path so the user isn't // left with "hooks registered, tool-db missing" and no clue what to run. process.stderr.write(`\nspotter install: tool-db seeding failed.\n`); process.stderr.write(` hooks are registered but tool-db is not ready.\n`); - process.stderr.write(` recover with: spotter db refresh and, for Codex, spotter db refresh --host-agent codex; for Cursor, spotter db refresh --host-agent cursor\n`); + process.stderr.write(` recover with: spotter db refresh and host-specific spotter db refresh --host-agent codex|cursor|grok\n`); throw err; } } @@ -214,6 +236,11 @@ export async function runInstall({ } else if (target === 'project' && !skipCursorHooks) { console.log(' Cursor hooks are not active: rerun `spotter install` where ~/.cursor exists'); } + if (grokHooksRegistered) { + console.log(' Grok audit hooks are active in new Grok sessions; findings are written to hook events'); + } else if (target === 'project' && !skipGrokHooks) { + console.log(' Grok hooks are not active: rerun `spotter install` where ~/.grok exists'); + } } async function readExistingAuditorContext(markerPath, resolveDefaultAuditorContextFn) { diff --git a/src/core/auditor-backend.mjs b/src/core/auditor-backend.mjs index 5118396..401a2bc 100644 --- a/src/core/auditor-backend.mjs +++ b/src/core/auditor-backend.mjs @@ -201,21 +201,21 @@ function selectByPolicy({ hostAgent, policy, projectConfig, env, isCodexCliAvail reason: 'codex_host', }; } - if (hostAgent === 'claude') { + if (hostAgent === 'claude' || hostAgent === 'grok') { const codexAvailable = isCodexCliAvailable({ env }); if (codexAvailable) { return { backend: 'codex-cli', mode: 'codex-cli', compatibility: 'none', - reason: 'claude_host_codex_cli_detected', + reason: `${hostAgent}_host_codex_cli_detected`, }; } return { backend: 'haiku', mode: 'compatibility_haiku', compatibility: 'current_haiku', - reason: 'claude_host_codex_cli_unavailable', + reason: `${hostAgent}_host_codex_cli_unavailable`, }; } throw new AuditorBackendError( diff --git a/src/core/hook-event-log.mjs b/src/core/hook-event-log.mjs index 6527fd2..39a8c59 100644 --- a/src/core/hook-event-log.mjs +++ b/src/core/hook-event-log.mjs @@ -45,8 +45,8 @@ export async function appendHookEvent({ projectRoot, host, event } = {}) { if (typeof projectRoot !== 'string' || projectRoot.length === 0) { throw new TypeError('appendHookEvent: projectRoot must be a non-empty string'); } - if (host !== 'claude' && host !== 'codex') { - throw new TypeError(`appendHookEvent: host must be "claude" or "codex" (got ${String(host)})`); + if (!['claude', 'codex', 'grok'].includes(host)) { + throw new TypeError(`appendHookEvent: host must be "claude", "codex", or "grok" (got ${String(host)})`); } if (!event || typeof event !== 'object') { throw new TypeError('appendHookEvent: event must be an object'); diff --git a/src/core/host-agent.mjs b/src/core/host-agent.mjs index 8749d78..f566cb9 100644 --- a/src/core/host-agent.mjs +++ b/src/core/host-agent.mjs @@ -1,4 +1,4 @@ -export const HOST_AGENTS = new Set(['claude', 'codex', 'automation', 'unknown']); +export const HOST_AGENTS = new Set(['claude', 'codex', 'grok', 'automation', 'unknown']); export function detectHostAgent({ explicitHostAgent = null, env = process.env } = {}) { if (explicitHostAgent !== null && explicitHostAgent !== undefined) { @@ -7,6 +7,7 @@ export function detectHostAgent({ explicitHostAgent = null, env = process.env } } if (env?.CLAUDECODE === '1' || env?.CLAUDE_CODE === '1') return 'claude'; if (env?.CODEX_SESSION_ID || env?.CODEX_SANDBOX) return 'codex'; + if (env?.GROK_SESSION_ID || env?.GROK_HOOK_EVENT) return 'grok'; if (env?.CI === 'true' || env?.GITHUB_ACTIONS === 'true') return 'automation'; return 'unknown'; } diff --git a/src/hooks/lib.mjs b/src/hooks/lib.mjs index 4838e44..e0e6b21 100644 --- a/src/hooks/lib.mjs +++ b/src/hooks/lib.mjs @@ -47,16 +47,15 @@ export function isSubagentCall(input) { } // Grok invokes Claude-compatible hook commands with a camelCase wire envelope. -// Spotter does not support Grok as a host: ignore that envelope before any -// project lookup, daemon, evaluation, or hook-event side effect. +// Current Grok also adds snake_case compatibility aliases, so session_id alone +// cannot distinguish its payload from Claude's. Native Grok hooks own this input. export function isUnsupportedNonClaudeEnvelope(input) { return input !== null && typeof input === 'object' && typeof input.sessionId === 'string' && input.sessionId.length > 0 && typeof input.hookEventName === 'string' - && input.hookEventName.length > 0 - && !Object.hasOwn(input, 'session_id'); + && input.hookEventName.length > 0; } // Walk up from startCwd looking for .spotter/marker.json. Returns the project diff --git a/src/host/adapters.mjs b/src/host/adapters.mjs index b25e1ed..9ca4c63 100644 --- a/src/host/adapters.mjs +++ b/src/host/adapters.mjs @@ -10,6 +10,7 @@ import { buildInvestigationSnapshot } from '../tool-db/investigate-claude.mjs'; import { buildCodexInvestigationSnapshot } from '../tool-db/investigate-codex.mjs'; import { buildCursorInvestigationSnapshot } from '../tool-db/investigate-cursor.mjs'; +import { buildGrokInvestigationSnapshot } from '../tool-db/investigate-grok.mjs'; const CLAUDE_ADAPTER = Object.freeze({ hostAgent: 'claude', @@ -40,11 +41,19 @@ const CURSOR_ADAPTER = Object.freeze({ buildCursorInvestigationSnapshot({ logFn, projectRoot }), }); +const GROK_ADAPTER = Object.freeze({ + hostAgent: 'grok', + toolDbFileName: 'tool-db.grok.json', + buildSnapshot: ({ logFn, projectRoot, grokBin }) => + buildGrokInvestigationSnapshot({ logFn, projectRoot, grokBin }), +}); + const ADAPTERS = Object.freeze({ claude: CLAUDE_ADAPTER, codex: CODEX_ADAPTER, automation: AUTOMATION_ADAPTER, cursor: CURSOR_ADAPTER, + grok: GROK_ADAPTER, }); export function normalizeToolDbHostAgent(hostAgent = 'claude') { @@ -54,7 +63,7 @@ export function normalizeToolDbHostAgent(hostAgent = 'claude') { if (Object.hasOwn(ADAPTERS, hostAgent)) { return hostAgent; } - throw new TypeError(`tool-db hostAgent must be claude, codex, automation, or cursor; got ${hostAgent}`); + throw new TypeError(`tool-db hostAgent must be claude, codex, automation, cursor, or grok; got ${hostAgent}`); } export function getHostAdapter(hostAgent = 'claude') { diff --git a/src/tool-db/investigate-grok.mjs b/src/tool-db/investigate-grok.mjs new file mode 100644 index 0000000..d3828d0 --- /dev/null +++ b/src/tool-db/investigate-grok.mjs @@ -0,0 +1,48 @@ +// Grok's effective configuration is the authority: it merges native and enabled +// Claude/Cursor compatibility sources. Inspect only the current project's view. +import { execFileWindowsSafe } from '../platform/spawn.mjs'; +import { listMcpToolsOne } from './investigate-mcp.mjs'; +import { describeServer } from './mcp-config.mjs'; + +async function execGrok(grokBin, args, projectRoot) { + const options = { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }; + if (projectRoot) options.cwd = projectRoot; + return (await execFileWindowsSafe(grokBin, args, options)).stdout; +} + +export async function buildGrokInvestigationSnapshot({ + logFn = () => {}, projectRoot, grokBin = 'grok', execGrokFn = execGrok, +} = {}) { + const snapshot = new Map(); + const inspection = JSON.parse(await execGrokFn(grokBin, ['inspect', '--json'], projectRoot)); + for (const item of [...(inspection.skills ?? []), ...(inspection.agents ?? [])]) { + if (['builtin', 'bundled'].includes(item?.source?.type) || item?.compatibilityStatus === 'disabled') continue; + const name = item?.invocableAs ?? item?.name; + if (typeof name === 'string' && name && typeof item.description === 'string' && item.description) { + snapshot.set(name, item.description); + } + } + + const servers = JSON.parse(await execGrokFn(grokBin, ['mcp', 'list', '--json'], projectRoot)); + if (!Array.isArray(servers)) throw new TypeError('Grok MCP list must be an array'); + for (const entry of servers) { + if (entry?.enabled === false || typeof entry?.name !== 'string') continue; + const server = describeServer(entry.name, entry); + if (!server) continue; + try { + const tools = await listMcpToolsOne({ server, logFn, projectRoot }); + for (const tool of tools) { + if (typeof tool.description === 'string' && tool.description) { + snapshot.set(grokVisibleMcpName(entry.name, tool.name), tool.description); + } + } + } catch (error) { + logFn(`grok mcp investigate failed for "${entry.name}": ${error.message}`); + } + } + return snapshot; +} + +export function grokVisibleMcpName(serverName, toolName) { + return `${serverName.replace(/[^A-Za-z0-9_-]/gu, '_')}__${toolName}`; +} diff --git a/src/tool-db/refresh.mjs b/src/tool-db/refresh.mjs index cdd2e4c..cf0e275 100644 --- a/src/tool-db/refresh.mjs +++ b/src/tool-db/refresh.mjs @@ -26,10 +26,11 @@ export async function refresh({ logFn = () => {}, claudeBin = 'claude', codexBin = 'codex', + grokBin = 'grok', hostAgent = 'claude', } = {}) { const adapter = getHostAdapter(hostAgent); - const snapshot = await adapter.buildSnapshot({ logFn, claudeBin, codexBin, projectRoot }); + const snapshot = await adapter.buildSnapshot({ logFn, claudeBin, codexBin, grokBin, projectRoot }); const toolNames = Array.from(snapshot.keys()); const investigate = async (name) => snapshot.get(name) ?? null; diff --git a/test/grok-host.test.mjs b/test/grok-host.test.mjs new file mode 100644 index 0000000..d2147eb --- /dev/null +++ b/test/grok-host.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { buildGrokInvestigationSnapshot, grokVisibleMcpName } from '../src/tool-db/investigate-grok.mjs'; +import { installGrokHooks, uninstallGrokHooks, grokHookDiagnostics, runGrokHook, readGrokCurrentTurnTools } from '../src/cli/grok-hook-cmd.mjs'; +import { isUnsupportedNonClaudeEnvelope } from '../src/hooks/lib.mjs'; + +test('Grok catalog uses effective skills and ignores bundled host capabilities', async () => { + const calls = []; + const snapshot = await buildGrokInvestigationSnapshot({ + projectRoot: '/example', + execGrokFn: async (_bin, args, cwd) => { + calls.push({ args, cwd }); + if (args[0] === 'inspect') return JSON.stringify({ + skills: [ + { name: 'my-skill', description: 'Useful skill', source: { type: 'user' } }, + { name: 'bundled', description: 'Standard', source: { type: 'bundled' } }, + ], + agents: [{ name: 'helper', description: 'Delegate', source: { type: 'project' } }], + }); + return '[]'; + }, + }); + assert.deepEqual([...snapshot], [['my-skill', 'Useful skill'], ['helper', 'Delegate']]); + assert.deepEqual(calls.map((entry) => entry.cwd), ['/example', '/example']); +}); + +test('Grok catalog MCP IDs follow the native server__tool namespace', () => { + assert.equal(grokVisibleMcpName('demo-server', 'search'), 'demo-server__search'); +}); + +test('Grok hooks install and uninstall only Spotter entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'spotter-grok-hooks-')); + const grokHome = join(root, '.grok'); + const path = join(grokHome, 'hooks', 'spotter.json'); + await mkdir(join(grokHome, 'hooks'), { recursive: true }); + await writeFile(path, JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'other product' }] }] } })); + await installGrokHooks({ grokHome }); + await installGrokHooks({ grokHome }); + assert.equal((await grokHookDiagnostics({ grokHome })).installed, true); + const file = JSON.parse(await readFile(path, 'utf8')); + assert.equal(file.hooks.Stop.length, 2); + assert.equal(file.hooks.Stop[0].hooks[0].command, 'other product'); + await uninstallGrokHooks({ grokHome }); + assert.equal((await grokHookDiagnostics({ grokHome })).installed, false); + assert.equal(JSON.parse(await readFile(path, 'utf8')).hooks.Stop[0].hooks[0].command, 'other product'); +}); + +test('Grok compatibility aliases do not route into Claude hooks', () => { + assert.equal(isUnsupportedNonClaudeEnvelope({ sessionId: 's', session_id: 's', hookEventName: 'user_prompt_submit' }), true); +}); + +test('Grok prompt audits host-local catalog and records findings without stdout', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'spotter-grok-prompt-')); + await mkdir(join(projectRoot, '.spotter')); + await writeFile(join(projectRoot, '.spotter', 'marker.json'), '{}'); + let catalogHost; + const events = []; + const evaluations = []; + await runGrokHook({ + event: 'user_prompt_submit', + readInput: async () => ({ hookEventName: 'user_prompt_submit', sessionId: 's', cwd: projectRoot, prompt: 'Use the tool' }), + readLocalFn: async ({ hostAgent }) => { catalogHost = hostAgent; return [{ name: 'mcp__demo__search', description: 'Search' }]; }, + createAuditorBackendFn: () => ({ name: 'mock', judge: async () => ({ pass: false, + findings: [{ toolName: 'mcp__demo__search' }], meta: { backend: 'mock' } }) }), + recordFn: async ({ host, event }) => events.push({ host, event }), + createEvaluationStoreFn: () => ({ recordTurn: (row) => evaluations.push(row), close() {} }), + }); + assert.equal(catalogHost, 'grok'); + assert.equal(events[0].host, 'grok'); + assert.deepEqual(events[0].event.missingTools, ['mcp__demo__search']); + assert.deepEqual(evaluations[0].proposedToolIds, ['mcp__demo__search']); +}); + +test('Grok SessionStart completes host-local refresh before returning', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'spotter-grok-start-')); + await mkdir(join(projectRoot, '.spotter')); + await writeFile(join(projectRoot, '.spotter', 'marker.json'), '{}'); + const events = []; + await runGrokHook({ + event: 'session_start', + readInput: async () => ({ hookEventName: 'session_start', sessionId: 's', cwd: projectRoot }), + refreshFn: async ({ hostAgent }) => { assert.equal(hostAgent, 'grok'); return new Map([['a', 'b']]); }, + recordFn: async ({ event }) => events.push(event), + }); + assert.equal(events[0].status, 'refreshed'); + assert.equal(events[0].toolCount, 1); +}); + +test('Grok transcript reader keeps only current turn tool calls', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spotter-grok-transcript-')); + const path = join(dir, 'updates.jsonl'); + const rows = [ + { params: { update: { sessionUpdate: 'tool_call', title: 'old' } } }, + { params: { update: { sessionUpdate: 'user_message_chunk' } } }, + { params: { update: { sessionUpdate: 'tool_call', title: 'read_file' } } }, + { params: { update: { sessionUpdate: 'tool_call', title: 'read_file' } } }, + { params: { update: { sessionUpdate: 'tool_call', title: 'mcp__demo__search' } } }, + ]; + await writeFile(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n'); + assert.deepEqual(await readGrokCurrentTurnTools(path), ['read_file', 'mcp__demo__search']); +}); + +test('Grok SessionEnd audits a turn left open when headless Stop omits final text', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'spotter-grok-end-')); + await mkdir(join(projectRoot, '.spotter')); + await writeFile(join(projectRoot, '.spotter', 'marker.json'), '{}'); + const transcriptPath = join(projectRoot, 'updates.jsonl'); + await writeFile(transcriptPath, [ + { params: { update: { sessionUpdate: 'user_message_chunk', content: { text: 'Question' } } } }, + { params: { update: { sessionUpdate: 'agent_message_chunk', content: { text: 'Answer' } } } }, + ].map((row) => JSON.stringify(row)).join('\n') + '\n'); + const events = []; + const closed = []; + const createEvaluationStoreFn = () => ({ + database: { prepare: () => ({ get: () => ({ observation_id: 'obs' }) }) }, + closeTurn: (row) => closed.push(row), + close() {}, + }); + await runGrokHook({ + event: 'session_end', + readInput: async () => ({ hookEventName: 'session_end', sessionId: 's', cwd: projectRoot, transcriptPath }), + readLocalFn: async () => [], + createAuditorBackendFn: () => ({ name: 'mock', judge: async (input) => { + assert.equal(input.finalResponse, 'Answer'); + return { pass: true, findings: [], meta: { backend: 'mock' } }; + } }), + createEvaluationStoreFn, + recordFn: async ({ event }) => events.push(event), + }); + assert.equal(events[0].hook, 'Stop'); + assert.equal(events[0].reason, 'session_end_fallback'); + assert.equal(closed[0].observationId, 'obs'); +}); + +test('Grok SessionEnd reports evaluation-store failure without failing the hook', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'spotter-grok-end-store-')); + await mkdir(join(projectRoot, '.spotter')); + await writeFile(join(projectRoot, '.spotter', 'marker.json'), '{}'); + const events = []; + const warnings = []; + await runGrokHook({ + event: 'session_end', + readInput: async () => ({ hookEventName: 'session_end', sessionId: 's', cwd: projectRoot }), + createEvaluationStoreFn: () => { throw new Error('private database failure'); }, + recordFn: async ({ event }) => events.push(event), + writeError: (warning) => warnings.push(warning), + }); + assert.equal(events[0].code, 'E_EVALUATION_STORE'); + assert.equal(events[0].status, 'degraded'); + assert.equal(warnings.length, 1); + assert.doesNotMatch(warnings[0], /private database failure/); +}); + +test('Grok Stop reports transcript failure without exposing its path', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'spotter-grok-stop-transcript-')); + await mkdir(join(projectRoot, '.spotter')); + await writeFile(join(projectRoot, '.spotter', 'marker.json'), '{}'); + const events = []; + const warnings = []; + await runGrokHook({ + event: 'stop', + readInput: async () => ({ hookEventName: 'stop', sessionId: 's', cwd: projectRoot, + lastAssistantMessage: 'Answer', transcriptPath: '/private/missing-transcript.jsonl' }), + recordFn: async ({ event }) => events.push(event), + writeError: (warning) => warnings.push(warning), + }); + assert.equal(events[0].code, 'E_TRANSCRIPT'); + assert.equal(warnings.length, 1); + assert.doesNotMatch(warnings[0], /private\/missing-transcript/); +}); diff --git a/test/hook-event-log.test.mjs b/test/hook-event-log.test.mjs index a04f6c1..0a7aa8b 100644 --- a/test/hook-event-log.test.mjs +++ b/test/hook-event-log.test.mjs @@ -50,7 +50,7 @@ test('appendHookEvent: writes a JSON line with schema, timestamp, and host', asy test('appendHookEvent: rejects unknown host values', async () => { await assert.rejects( () => appendHookEvent({ projectRoot: '/tmp', host: 'whatever', event: {} }), - /host must be "claude" or "codex"/ + /host must be "claude", "codex", or "grok"/ ); }); diff --git a/test/install.test.mjs b/test/install.test.mjs index 29e8c8f..d898e9d 100644 --- a/test/install.test.mjs +++ b/test/install.test.mjs @@ -306,8 +306,8 @@ test('install: re-run seeds tool-db even when hooks are unchanged (v1.1.1 regres callCount++; return new Map(); }; - await runInstall({ target: 'project', autoYes: true, cwd: dir, refreshFn: mockRefresh, skipCodexHooks: true, skipCursorHooks: true }); - await runInstall({ target: 'project', autoYes: true, cwd: dir, refreshFn: mockRefresh, skipCodexHooks: true, skipCursorHooks: true }); + await runInstall({ target: 'project', autoYes: true, cwd: dir, refreshFn: mockRefresh, skipCodexHooks: true, skipCursorHooks: true, skipGrokHooks: true }); + await runInstall({ target: 'project', autoYes: true, cwd: dir, refreshFn: mockRefresh, skipCodexHooks: true, skipCursorHooks: true, skipGrokHooks: true }); // Both calls must invoke refresh. The 2nd is the direct regression test for // the early-return that v1.1.1 removed — before that fix, the 2nd call // short-circuited at "hooks already registered" and never touched refresh. @@ -328,6 +328,7 @@ test('install: registers Codex hooks when Codex CLI is present', async () => { skipRefresh: true, skipCodexHooks: false, skipCursorHooks: true, + skipGrokHooks: true, codexCliPresentFn: () => true, installCodexHooksFn: async () => { calls.push('install'); @@ -358,6 +359,7 @@ test('install: seeds Codex tool-db when Codex hooks are registered', async () => refreshFn: mockRefresh, skipCodexHooks: false, skipCursorHooks: true, + skipGrokHooks: true, codexCliPresentFn: () => true, installCodexHooksFn: async () => ({ hooksPath: '/home/test/.codex/hooks.json', @@ -385,6 +387,7 @@ test('install: skips Codex tool-db seed when Codex CLI is unavailable', async () refreshFn: mockRefresh, skipCodexHooks: false, skipCursorHooks: true, + skipGrokHooks: true, codexCliPresentFn: () => false, installCodexHooksFn: async () => { throw new Error('should not install Codex hooks'); @@ -409,7 +412,7 @@ test('install: refresh failure surfaces recovery hint on stderr', async () => { throw new Error('simulated MCP enumeration failure'); }; await assert.rejects( - runInstall({ target: 'project', autoYes: true, cwd: dir, refreshFn: failingRefresh, skipCodexHooks: true, skipCursorHooks: true }), + runInstall({ target: 'project', autoYes: true, cwd: dir, refreshFn: failingRefresh, skipCodexHooks: true, skipCursorHooks: true, skipGrokHooks: true }), /simulated MCP/ ); const stderrText = captured.join(''); @@ -438,6 +441,7 @@ test('install: skips Cursor tool-db seed when Cursor home is unavailable', async refreshFn: mockRefresh, skipCodexHooks: true, skipCursorHooks: false, + skipGrokHooks: true, cursorHomePresentFn: () => false, installCursorHooksFn: async () => { throw new Error('should not install Cursor hooks'); @@ -464,6 +468,7 @@ test('install: seeds Cursor tool-db when Cursor home is present', async () => { refreshFn: mockRefresh, skipCodexHooks: true, skipCursorHooks: false, + skipGrokHooks: true, cursorHomePresentFn: () => true, installCursorHooksFn: async () => ({ hooksPath: '/home/test/.cursor/hooks.json', @@ -476,6 +481,23 @@ test('install: seeds Cursor tool-db when Cursor home is present', async () => { } }); +test('install: seeds Grok catalog when native hooks are registered', async () => { + const dir = await mkdtemp(join(tmpdir(), 'spotter-install-grok-seed-')); + const refreshHosts = []; + try { + await runInstall({ + target: 'project', autoYes: true, cwd: dir, + refreshFn: async ({ hostAgent }) => { refreshHosts.push(hostAgent); return new Map(); }, + skipCodexHooks: true, skipCursorHooks: true, skipGrokHooks: false, + grokHomePresentFn: () => true, + installGrokHooksFn: async () => ({ hooksPath: '/home/test/.grok/hooks/spotter.json' }), + }); + assert.deepEqual(refreshHosts, ['claude', 'grok']); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('uninstall (project): removes .spotter/marker.json but keeps .spotter dir', async () => { const dir = await mkdtemp(join(tmpdir(), 'spotter-uninstall-marker-')); try { From 82e468eba84c3d38a2521f5bbabb6f4afe374f0a Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:30:19 +0000 Subject: [PATCH 6/7] Prepare Spotter 1.8.0 release documentation --- CHANGELOG.md | 7 +++++++ README.ja.md | 4 +++- README.md | 4 +++- docs/01_catalog-design.md | 18 +++++++++++------- docs/02_spotter-claude-contract.md | 3 ++- docs/04_operational-slo.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 8 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4c08e..400dfbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ 各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。 現行runtime契約は[`docs/00_overview.md`](https://github.com/kitepon/Spotter/blob/main/docs/00_overview.md)から辿る。 +## 1.8.0 — 2026-09-27 + +- Grok Buildのnative hookとhost専用カタログを追加。Linux、macOS、Windows nativeの実セッションで入力時・応答後の監査を確認した。Grok 1.0.41は受動hookのstdoutを会話へ渡さないため、findingは構造eventと評価DBに記録する。 +- Grokのheadless実行で最終応答つき`Stop`が欠けたturnを`SessionEnd`で補完する。Windowsでは`SessionStart`のカタログ更新完了を待ち、初回監査から有効なツールを使う。 +- Windows Cursorのhook入力に付くUTF-8 BOMを受け付け、Git管理外のprojectでもCodex CLI監査を実行できるようにする。 +- dashboardの現行構成から廃止済みFOX WSL2端末を外し、3端末を表示する。 + ## 1.7.3 — 2026-09-24 - 退役するcodex-sidecarの明示CLI、追加監査dispatch、primary auditor backend指定、診断と関連コードを削除する。Jev、Codex CLI、Haikuの主監査は維持する。 diff --git a/README.ja.md b/README.ja.md index ad66a1b..8e20dd8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -251,6 +251,8 @@ spotter codex-hook install # Codex native hooks の修復 / 明示登録 (通常は spotter install が実行) spotter codex-hook diagnostics # Codex hook の登録/readiness を診断。trust は /hooks で review +spotter grok-hook diagnostics + # Grok native監査hookの登録を確認 spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json --recent-turns 2 --body-cap 600 # pinned auditor model profile を再現可能に比較する experimental eval spotter uninstall # hook 登録を解除 (~/.spotter は残す) @@ -273,7 +275,7 @@ project/tool内訳、非採用case、監査対象request、任意の提案時Thr health確認は端末一覧request時だけなので、端末がofflineでもbackground監視や retry queueを作らず、その端末だけを切り離せる。 -4端末のservice、reverse tunnel、Caddy/Cloudflare構成は +3端末のservice、reverse tunnel、Caddy/Cloudflare構成は [docs/11_dashboard-operations.md](https://github.com/kitepon/Spotter/blob/main/docs/11_dashboard-operations.md)を参照。 Windows同梱のTask Scheduler installerはnpm・SSH用の対話ユーザープロファイルを維持しつつ、 dashboardの2つのPowerShell actionを非対話・console非表示で起動する。 diff --git a/README.md b/README.md index 8cdf9f2..fbb71c1 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,8 @@ spotter codex-hook install # repair / explicitly register Codex native hooks (normally handled by spotter install) spotter codex-hook diagnostics # check Codex hook registration/readiness; trust is reviewed with /hooks +spotter grok-hook diagnostics + # check Grok native audit hook registration spotter auditor model-matrix --fixtures test/fixtures/auditor-model-matrix.v2.json --recent-turns 2 --body-cap 600 # experimental reproducible comparison of pinned auditor model profiles spotter uninstall # remove hooks from this project (leaves ~/.spotter intact) @@ -280,7 +282,7 @@ audited by Spotter, and optional proposal-time Throughline evidence. The hub che when the device list is requested, so an offline terminal is isolated without a background monitor or retry queue. -The reference four-terminal service, reverse-tunnel, and Caddy/Cloudflare layout is documented in +The reference three-terminal service, reverse-tunnel, and Caddy/Cloudflare layout is documented in [docs/11_dashboard-operations.md](https://github.com/kitepon/Spotter/blob/main/docs/11_dashboard-operations.md). On Windows, the bundled Task Scheduler installer keeps the interactive user's profile for npm and SSH while starting both dashboard PowerShell actions non-interactively with hidden console windows. diff --git a/docs/01_catalog-design.md b/docs/01_catalog-design.md index aa97ac0..fae21d5 100644 --- a/docs/01_catalog-design.md +++ b/docs/01_catalog-design.md @@ -1,6 +1,6 @@ # カタログ設計思想 — ユーザー追加ツールだけをauditorへ渡す -この文書は現行のClaude / Codex auditor pathが共有するカタログ設計を説明する。 +この文書は現行のClaude / Codex / Cursor / Grokが共有するカタログ設計を説明する。 実挙動の権威は`src/tool-db/`と対応testである。 `UserPromptSubmit` / `Stop` のCodex host対応は完了済み。現行 backend policy は [`02_spotter-claude-contract.md`](02_spotter-claude-contract.md) を参照し、完了済みの移行ログは @@ -51,7 +51,7 @@ auditorは次の順序で判定する。 auditorは「主役AIが呼び忘れているツールがあれば、その名前を返す」役。**schema までは要らない**。 呼び方を知るのは主役AIの責任(必要なら`ToolSearch`などでschemaを取得する)。 -したがって提案可能なカタログとしてauditorへ渡すのは **`{ツール名, 説明}` のペアだけ**。Claude / Codexそれぞれの +したがって提案可能なカタログとしてauditorへ渡すのは **`{ツール名, 説明}` のペアだけ**。各hostの host-local DBから、そのturnのauditor入力へ投入する。 ``` @@ -121,14 +121,18 @@ logしない。DB自体のJSON/schema違反は ## description の取得フロー — 3 段階のキャッシュ DB -セッション開始時、Spotter は **その host セッションで使える MCP / スキル / サブエージェント の一覧 (名前)** を取得する。Claude と Codex は利用可能ツールが違うため、ローカル DB は host 別に分ける。 +セッション開始時、Spotter は **その host セッションで使える MCP / スキル / サブエージェント の一覧 (名前)** を取得する。利用可能ツールはhostごとに違うため、ローカル DB を分ける。 1. **プロジェクト host-local DB** - Claude: `/.spotter/tool-db.json` - Codex: `/.spotter/tool-db.codex.json` + - Cursor: `/.spotter/tool-db.cursor.json` + - Grok: `/.spotter/tool-db.grok.json` 2. **host-global DB** - Claude: `~/.spotter/tool-db.json` - Codex: `~/.spotter/tool-db.codex.json` + - Cursor: `~/.spotter/tool-db.cursor.json` + - Grok: `~/.spotter/tool-db.grok.json` 3. **どちらにも無ければ「調べる」** — 各提供者から description を取得。**取得結果はグローバルとローカルの両方に追記する** ``` @@ -148,8 +152,8 @@ logしない。DB自体のJSON/schema違反は - **作業負荷の軽減**: 毎セッション全部問い合わせると遅い・無駄。一度引いた description はキャッシュして使い回す - **二重書き込みの理由 (v1.2.0 以降の役割再定義)**: - - **host-local**: **各 host の監査が使う唯一の入力源**。Claude daemon は `.spotter/tool-db.json`、Codex hooks は `.spotter/tool-db.codex.json` を読み、その host / project の現時点の discovery 結果と一致する - - **host-global**: **同じ host の他プロジェクトでの description 再利用キャッシュ**。Claude と Codex でも分離する。daemon / Codex hook の audit には混ぜない (混ぜると過去の別プロジェクトや別 host で discover したツールが現プロジェクトの監査視野に幻として漏れる) + - **host-local**: **各 host の監査が使う唯一の入力源**。Claude daemon、Codex hooks、Grok hooksは各hostのproject DBを読む。Cursorは専用DBを更新する + - **host-global**: **同じ host の他プロジェクトでの description 再利用キャッシュ**。hostごとに分離し、監査入力へ混ぜない (過去の別プロジェクトや別hostのツールが現在の監査へ混入するため) - **グローバル → host-local の write-through**: 次セッションで host-local 単独ヒットになり余計な参照が走らない - **drift 補正**: host-local と host-global で同一ツールの description が異なるとき、再調査して両方を上書きする。提供者の description が単一の真実源として優先される - **明示的な無効化機構は持たない**: TTL や version tracking のような仕組みは入れない。drift 補正が間接的な無効化として機能する @@ -176,8 +180,8 @@ Codex host の refresh は [investigate-codex.mjs](../src/tool-db/investigate-co ## 収集タイミング (v1.1.0 以降) -- **`spotter install` 時**: `refresh({projectRoot, hostAgent:"claude"})` を同期実行。初回 setup で Claude 用 tool-db.json を seed、install 完了時点で次セッションの daemon が audit に使える状態にする。Codex CLI が見える project install では Codex hooks 登録後に `refresh({projectRoot, hostAgent:"codex"})` も同期実行し、初回 Codex セッションから `.spotter/tool-db.codex.json` を読める状態にする。refresh throw 時は hook 登録も含めて install 自体を失敗扱い (§0 準拠) -- **SessionStart hook 発火時**: Claude SessionStart は `spotter db refresh --host-agent claude` を detached child として bg 起動 ([session-start.mjs](../src/hooks/session-start.mjs) の `spawnRefreshDetached`)。Codex native SessionStart は `spotter db refresh --host-agent codex` を detached child として bg 起動 ([codex-hook-cmd.mjs](../src/cli/codex-hook-cmd.mjs) の `runCodexSessionStartHook`)。hook 自体は即 return、drift 追従 (新規 MCP / スキル / サブエージェントの追加、削除) は**次セッション以降**に反映される。Claude daemon は起動時の Claude DB を固定保持し、Codex hooks は次 hook 実行時に Codex DB を読み直す +- **`spotter install` 時**: Claude DBを同期作成する。Codex CLI、Cursor home、Grok homeがある場合は各native hookを登録し、各host DBも同期作成する。refresh失敗時はhook登録後でもinstall自体を失敗として知らせる +- **SessionStart hook 発火時**: Claude、Codex、Cursorは切り離した子processで各DBを更新する。Grokは初回セッションからカタログを使えるよう、更新完了を待つ。Claude daemonは起動時のDBを固定保持し、CodexとGrok hooksは次のhook実行時に各DBを読む - **`spotter db refresh` CLI**: 明示的に叩いた場合も同じ refresh ロジック。`--host-agent codex` を付けると `.spotter/tool-db.codex.json` を更新し、Claude DB には触れない。Claude / Codex とも SessionStart の自動化が通常経路なので、手動実行は smoke / 修復 / 即時反映用 - **`spotter db rebuild` CLI**: host-local + host-global DB を wipe してから refresh。既定は Claude local + Claude global、`--host-agent codex` なら Codex local + Codex global。カタログ設計変更時 (v1.0.0 の切り替え等) のクリーンスレート用、通常運用では不使用 diff --git a/docs/02_spotter-claude-contract.md b/docs/02_spotter-claude-contract.md index 753c371..560fd6b 100644 --- a/docs/02_spotter-claude-contract.md +++ b/docs/02_spotter-claude-contract.md @@ -153,7 +153,8 @@ Spotter子backendのhookを除外する。Claude互換hookへ同じGrokイベン `SessionStart`でrefreshを完了させてから監査へ進む。`Stop`はGrok transcriptの現行turnのtool callと `lastAssistantMessage`を使う。headless実行で最終応答つき`Stop`が欠けた場合は、`SessionEnd`で 未完の評価turnだけをtranscriptから監査して閉じる。Grok 1.0.41は受動hookのstdoutを無視するため、findingは -`.spotter/hook-events.jsonl`と評価DBに記録し、会話へは注入しない。 +`.spotter/hook-events.jsonl`と評価DBに記録し、会話へは注入しない。評価DB参照とtranscript読取が +失敗した場合は固定stderrと構造eventを残し、hostを停止させない。 - Claude `SessionStart` - returns without spawning when any child-process variable above is set. diff --git a/docs/04_operational-slo.md b/docs/04_operational-slo.md index dca64c6..931800e 100644 --- a/docs/04_operational-slo.md +++ b/docs/04_operational-slo.md @@ -9,6 +9,7 @@ SLO(Service Level Objective)は「通常運用で、どの程度の速さ・ - production auditor: Jev認証設定時はJev(model正本は`src/core/jev-backend.mjs`)、未設定時は既存backend選択に従う。 - Codex native `UserPromptSubmit` / `Stop` - Claude host の primary auditor(backend 別に集計し、Codex と混ぜない) +- Grok native `UserPromptSubmit` / `Stop`(host別に集計し、会話内表示の成功率には含めない) ## 運用 SLO @@ -22,6 +23,7 @@ SLO(Service Level Objective)は「通常運用で、どの程度の速さ・ | auth / usage limitを除くbackend失敗率 | 2%以下 | 2%以下 | Codex nativeは外側Hook 60秒・auditor child 20秒、Claude hostは外側Hook 60秒・daemon/backend 45秒である。 +Grok nativeは外側Hook 60秒・auditor child 45秒である。GrokのSessionStartはcatalog更新完了を待つ。 SLOを満たさない時に上限だけを延ばして正常扱いにはしない。対応順は (1) Hook重複除去、 (2) catalog/prompt workload削減、(3) model/effort再評価、 (4) cache/skip条件、(5) 別承認でtimeout変更、とする。認証失効・利用上限・非対応modelは別障害として diff --git a/package-lock.json b/package-lock.json index f609812..0aff340 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-spotter", - "version": "1.7.3", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-spotter", - "version": "1.7.3", + "version": "1.8.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 37b4c73..aafe432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-spotter", - "version": "1.7.3", + "version": "1.8.0", "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離", "type": "module", "bin": { From 894f28baa5504cb47a026e54781d51193984862a Mon Sep 17 00:00:00 2001 From: quolu <226230081+quolu@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:18:07 +0000 Subject: [PATCH 7/7] Add OIDC npm publishing workflow --- .github/workflows/publish.yml | 39 +++++++++++++++++++++++++++++++++++ README.ja.md | 16 ++++++++++---- README.md | 16 ++++++++++---- 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..10fdd1a --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,39 @@ +name: Publish to npm + +on: + workflow_dispatch: + inputs: + version: + description: Package version already merged into main + required: true + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Verify requested version + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + node -e 'const version = require("./package.json").version; if (version !== process.env.EXPECTED_VERSION) throw new Error(`Expected ${process.env.EXPECTED_VERSION}, got ${version}`)' + - run: npm ci --no-audit --no-fund + - run: npm run verify:release-commit + - run: npm publish --provenance diff --git a/README.ja.md b/README.ja.md index 8e20dd8..d2d6639 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,7 +18,7 @@ ## 所有境界 -本repositoryはSpotter製品面の全体、すなわち監査挙動、Claude/Codex hook adapter、 +本repositoryはSpotter製品面の全体、すなわち監査挙動、Claude/Codex/Cursor/Grok hook adapter、 project marker、catalog discoveryとhost-local tool DB、評価store、dashboard server、 diagnostics、installer、release packagingを所有します。 [dotagents](https://github.com/kitepon/dotagents)が所有するのは共有agent指示と、 @@ -90,12 +90,20 @@ spotter uninstall # このプロジェクトの hook 登録を解除 ```bash npm uninstall -g claude-spotter -npm install -g claude-spotter +npm install -g claude-spotter@1.8.0 spotter --version spotter install -y -spotter codex-hook install ``` +公開担当は検証済みcommitを`main`へmergeし、`main`から +[Publish to npm](https://github.com/kitepon/Spotter/actions/workflows/publish.yml)へpackage versionを指定して実行します。 +workflowは指定versionと`main`への着地を検査してから`npm publish`します。 +初回だけ[npm packageのAccess設定](https://www.npmjs.com/package/claude-spotter/access)で +GitHub ActionsのTrusted Publisherを登録してください。ownerは`kitepon`、repositoryは`Spotter`、 +workflow filenameは`publish.yml`、environmentは空欄、直接の`npm publish`を許可します。 +GitHubが管理するrunnerのOIDCを使うため、以降の公開にnpm tokenの保存やCLIログインは要りません。 +公開後はregistryのversionを確認し、対象端末へそのversionを指定してインストールします。 + ## 動作要件 - **Node.js 22.13 以上**(npmの`engines.node`と同じ) @@ -330,7 +338,7 @@ profile から production へ自動昇格しません。`latest` alias や - **失敗は声に出して縮退、hostを固めない** (v1.4.15) — この版でbackend failureによるpromptのsilent消去を止めた。v1.4.19以降もnon-blocking挙動は維持し、旧model可視警告文は固定`systemMessage`・stderr・構造event診断へ置換した - **プラグイン形式の MCP サーバー対応** — `plugin:everything-claude-code:context7` のように名前に内部コロンを含むサーバーを正しくパースし、配下のツールをカタログに取り込めるようになった (旧版はこの形式のサーバーをすべて単一の `"plugin"` に潰して、Claude の監査から silent に脱落させていた) - **プロジェクト単位の監査隔離** — daemon が監査に使うのはローカル DB のみ。グローバル DB は description 再利用キャッシュに役割限定。**他プロジェクト**でインストールしたツールが現プロジェクトの監査に混入することはない -- **手放しでカタログ維持** — `spotter install` が Claude DB を自動 seed、Claude / Codex それぞれの SessionStart が host-local DB を bg refresh する。手書き管理は一切不要 +- **手放しでカタログ維持** — `spotter install`が利用可能なhostのDBを作る。Claude / Codex / CursorはSessionStartでバックグラウンド更新し、Grokは初回監査前に更新完了を待つ - **Codex native hooks** — Codex host は primary auditor backend として Codex CLI を使い、`.spotter/tool-db.codex.json` を Claude DB と分離し、backend failure は Haiku fallback ではなく明示 error として扱う - **監査対象** — ユーザー追加分 (MCP / スキル / サブエージェント) のみ。Claude Code 本体側のツールは意図的に対象外 (Claude は元から自発率が高いため) - **実装規範** — フォールバック禁止 / silent fallback 禁止 / 暫定コード禁止 ([AGENTS.md §0](https://github.com/kitepon/Spotter/blob/main/AGENTS.md)) diff --git a/README.md b/README.md index fbb71c1..be9f99b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Built and maintained by [Quo](https://x.com/QLyun35332) at [kitepon.dev](https:/ ## Ownership boundary This repository owns the complete Spotter product surface: auditor behavior, -Claude/Codex hook adapters, project markers, catalog discovery and host-local +Claude/Codex/Cursor/Grok hook adapters, project markers, catalog discovery and host-local tool databases, evaluation storage, dashboard servers, diagnostics, installers, and release packaging. [dotagents](https://github.com/kitepon/dotagents) owns shared agent instructions and the optional factory-reporter configuration @@ -91,12 +91,20 @@ Release install smoke: ```bash npm uninstall -g claude-spotter -npm install -g claude-spotter +npm install -g claude-spotter@1.8.0 spotter --version spotter install -y -spotter codex-hook install ``` +Maintainer release: merge the tested release commit into `main`, then run +[Publish to npm](https://github.com/kitepon/Spotter/actions/workflows/publish.yml) from `main` with the package version. +The workflow checks the requested version and the `main` ancestry gate before `npm publish`. +For the one-time npm setup, open the [package access settings](https://www.npmjs.com/package/claude-spotter/access) +and add a GitHub Actions trusted publisher: owner `kitepon`, repository `Spotter`, +workflow filename `publish.yml`, no environment, and allow direct `npm publish`. +The GitHub-hosted workflow uses OIDC, so later releases need no stored npm token or CLI login. +After publication, verify the registry version and install that exact version on each target host. + ## Requirements - **Node.js 22.13+** @@ -353,7 +361,7 @@ the production values for controlled experiments; diagnostics mark overrides as - **Failures degrade loudly, never freeze the host** (v1.4.15) — this release stopped backend failure from silently erasing a prompt. Since v1.4.19, the non-blocking behavior remains but the old model-visible warning text is replaced by fixed `systemMessage`, stderr, and structured event diagnostics - **Plugin-scoped MCP servers** — names like `plugin:everything-claude-code:context7` (with internal colons) are now parsed correctly and their tools enter the catalog. Earlier versions silently collapsed all plugin MCP servers into a single literal `"plugin"`, dropping their tools from Claude's audit - **Per-project / per-host audit isolation** — the daemon audits against the local DB only; global DBs are host-specific description caches. Tools discovered in *other* projects or another host can never bleed into this project's audit set -- **Zero-touch catalog** — `spotter install` seeds the Claude DB automatically; Claude and Codex SessionStart hooks keep their host-local DBs fresh in the background. You never have to maintain the tool list by hand +- **Zero-touch catalog** — `spotter install` seeds each available host's DB. Claude, Codex, and Cursor refresh in the background; Grok waits for refresh before its first audit - **Codex native hooks** — Codex host uses Codex CLI as the primary auditor backend, keeps a separate `.spotter/tool-db.codex.json`, and surfaces backend failures explicitly instead of falling back to Haiku - **Audit scope** — only user-added surface (MCP servers / skills / sub-agents). Claude Code's built-in tools are intentionally out of scope; Claude already uses those reliably - **Implementation invariants** — no fallbacks, no silent failures, no provisional code (see [§0 in AGENTS.md](https://github.com/kitepon/Spotter/blob/main/AGENTS.md))