claude-spotter 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.4
4
+
5
+ **MCP 投資ロジックの 2 件の穴を修正**。どちらも「名乗っているスコープ」と「実際に参照されるスコープ」が一致していない silent mismatch。前者は projectRoot 引数が効かない経路、後者は baseline が現実を無視して常に 25 件投入される経路。
6
+
7
+ ### 変更点
8
+
9
+ - **編集 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)**: `listMcpServers` / `getStdioConfig` が projectRoot を受け取っておきながら `execClaude(claude mcp list / mcp get)` に `cwd` を渡していなかったため、`.mcp.json` 読み込みと claude CLI の walk-up が別プロジェクトを見る可能性があった。`cwd: projectRoot` を付与し、`listMcpToolsOne` を通じて projectRoot を伝搬するシグネチャに変更。通常は `process.cwd() === projectRoot` で表面化しないが、API の意味論を実装に揃える
10
+ - **編集 [src/tool-db/claude-ai-baseline.mjs](src/tool-db/claude-ai-baseline.mjs)**: flat な `listClaudeAiNames` / `getClaudeAiDescription` を削除、server 単位の `getClaudeAiBaselineByServer()` に再編。Gmail / Calendar / Drive を個別集合として保持し、呼び出し側で現実に存在するサーバーのみ注入できるようにした
11
+ - **編集 [src/tool-db/refresh.mjs](src/tool-db/refresh.mjs)**: `buildInvestigationSnapshot` で `listMcpServers` の結果に基づき baseline を filter。`claude mcp list` に `claude.ai Gmail` / `claude.ai Google Calendar` / `claude.ai Google Drive` が存在しない環境 (隔離 `CLAUDE_CONFIG_DIR`, claude.ai OAuth 未連携, 部分連携) では該当 baseline は投入されない。純粋関数 `filterClaudeAiBaseline` を named export として切り出しテスト可能にした
12
+ - **編集 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: `filterClaudeAiBaseline` の回帰テスト 3 件追加 — 全 3 サーバー存在 / Gmail のみ存在 / 全不在
13
+
14
+ ### 背景
15
+
16
+ #### projectRoot の silent mismatch
17
+
18
+ v0.10.0 で `.mcp.json` の project scope 対応を入れた際、`readMcpServers({projectRoot})` は projectRoot を尊重するようにしたが、同じ関数内で spawn している `claude mcp list` / `claude mcp get` には `cwd` を渡し忘れていた。claude CLI は cwd から親方向に walk-up して `.mcp.json` を探すため、Spotter が引数で指定した projectRoot と claude CLI が勝手に見つけた project scope が乖離する可能性が残っていた。
19
+
20
+ #### claude.ai baseline の無条件注入
21
+
22
+ v0.8.0 で claude.ai OAuth 系 MCP (Gmail / Calendar / Drive) を手書き baseline として導入した際、「live HTTP investigate が成功した場合 override される」という想定で無条件注入ロジックを置いていた。しかし claude.ai 系は `.mcp.json` に載らず OAuth proxy 経由のため、`listMcpToolsAll` の investigate 対象にそもそも入らない = override 経路は発動不能。結果、claude.ai 未連携 / 部分連携環境 (隔離 `CLAUDE_CONFIG_DIR` での bellbot 等) で最大 25 件の幻ツールが catalog に残り、Bell が呼べないツールを Spotter が推奨する誤検出源になっていた。
23
+
24
+ ### 設計判断
25
+
26
+ - **`listMcpToolsAll` のシグネチャは触らない**: baseline filter 用に `listMcpServers` を buildInvestigationSnapshot で先に呼ぶと、内部で listMcpToolsAll がもう一度 CLI spawn する。pre-resolved servers 引数で避けられるが、API 表面を増やすコストに対し `claude mcp list` は 0.5-2s の 1 度だけなので受容
27
+ - **診断ログ追加**: baseline 注入時に `claude.ai baseline injected: N tools from <server list>` を logFn に出力。どの環境で何件入ったか後から追えるようにした
28
+ - **後方互換 export は削除**: `listClaudeAiNames` / `getClaudeAiDescription` は [src/index.mjs](src/index.mjs) に re-export されておらず、外部利用の形跡なし。残しても drift 源になるため削除
29
+
30
+ ## 1.1.3
31
+
32
+ **v1.1.x の実装進展にドキュメントを追従させる docs-only リリース**。コード変更なし。npm package tarball 同梱の README が古い手順を指していたため再 publish。
33
+
34
+ ### 変更点
35
+
36
+ - **編集 [README.md](README.md)**:
37
+ - 先頭バナーを v1.0.0 → v1.1.2 に更新、install 自動 seed + SessionStart bg refresh の新挙動を要約
38
+ - 「install 後に `spotter db refresh` を手動実行」の古い手順を削除、v1.1.0 以降の自動化を明記
39
+ - カタログ収集経路を 4 系統 (MCP / 組込み 遅延ツール) → (MCP / スキル / サブエージェント / claude.ai baseline) に書き直し (v1.0.0 の設計転換反映)
40
+ - コマンド表の `spotter db refresh` コメント更新 (「組込み 遅延ツール」削除、v1.1.0 以降は通常不要な旨追記)、`spotter db rebuild` の挙動を local+global wipe に訂正
41
+ - 設計ドキュメント節を 4 本立て (catalog-design / open-issues / CLAUDE.md §0 / spotter-plan 歴史記録) に再編
42
+ - Haiku timeout 表記を v0.5.0 (30s) → v0.13.1 (45s) に訂正
43
+ - **編集 [docs/catalog-design.md](docs/catalog-design.md)**:
44
+ - 新節「収集タイミング (v1.1.0 以降)」追加 — install 同期 seed / SessionStart bg refresh / db refresh / db rebuild の 4 経路を整理
45
+ - 歴史節に v1.1.x の「収集タイミング自動化」を追記
46
+ - **編集 [docs/spotter-plan.md](docs/spotter-plan.md)**:
47
+ - 冒頭に「v0.1 時点の設計議事録」である旨のブリッジ追加、現行設計の真実源 (catalog-design.md / open-issues.md / CLAUDE.md) へのリンクを明示
48
+
3
49
  ## 1.1.2
4
50
 
5
51
  **v1.1.1 の code-review で発見した 2 件を修正**。Spotter 自身が監査役として指摘し、実装を補正する自己ドッグフーディング。
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Spotter
2
2
 
3
- > **v1.0.0 released 2026-04-20**. **監査対象をユーザー追加分 (MCP / スキル / サブエージェント) に絞り込み**。Claude Code 本体が提供するツールは監査から外す Bell は本体側を使いこなしており呼び忘れ率が低い、また即時 / 遅延の境界がバージョンで動的に変わり手書き baseline が追従できない、の 2 点による設計転換。新規にスキル (SKILL.md frontmatter) とサブエージェント (.md frontmatter) の自動収集を追加、ECC プラグイン有効化状態から 181 skills + 38 agents live 取得。設計思想は [docs/catalog-design.md](docs/catalog-design.md)、変更詳細は [CHANGELOG](CHANGELOG.md)。
3
+ > **v1.1.2 released 2026-04-20**. **install が tool-db を自動構築 + SessionStart で drift 自動追従**。`spotter install` 実行時に MCP / スキル / サブエージェントを discover して tool-db.json を seed、以降 Claude Code セッション起動ごとに SessionStart hook detached `spotter db refresh` bg 発火 (反映は次セッション以降)。手動の `spotter db refresh` は不要に。監査対象は v1.0.0 でユーザー追加分 (MCP / スキル / サブエージェント) に絞り込み済み、本プロジェクトでの実測で 268 resolved (MCP 40 + skills 181 + agents/bare 47)。設計思想は [docs/catalog-design.md](docs/catalog-design.md)、変更詳細は [CHANGELOG](CHANGELOG.md)。
4
4
 
5
5
  **気づく役と実行する役を分離する。** Spotter は Claude Code の横で静かに並走し、Bell (主役の Claude) が**ツールを呼び忘れたとき**に指摘する監査役です。
6
6
 
@@ -44,7 +44,7 @@ Stop hook → Spotter が応答と使用済みツールを見て最終チェッ
44
44
  見落としあれば差し戻し (max 1 回、Claude Code の stop_hook_active で自動担保)
45
45
  ```
46
46
 
47
- 監査対象のツール (name + description) は `~/.spotter/tool-db.json` (グローバル) と `<project>/.spotter/tool-db.json` (ローカル) に格納されます。install 後に `spotter db refresh` を実行すると、`claude mcp list` MCP サーバーを列挙し、各サーバーの `tools/list` を JSON-RPC で叩いて description を収集、Claude Code 組込み 遅延ツール (WebSearch / TodoWrite ) は同梱の baseline からカバーします。**手書きでツールリストを管理する必要はありません**。
47
+ 監査対象のツール (name + description) は `~/.spotter/tool-db.json` (グローバル) と `<project>/.spotter/tool-db.json` (ローカル) に格納されます。**v1.1.0 以降、`spotter install` が初回 seed を自動実行し、Claude Code セッション起動ごとに SessionStart hook が bg で `spotter db refresh` を走らせる**ため、通常の運用で手動コマンドを叩く必要はありません。収集経路は (1) MCP サーバー: user/project scope の `.mcp.json` + `claude mcp list` で列挙、各サーバーの `tools/list` を JSON-RPC で取得、HTTP/SSE transport にも対応、(2) スキル: user/project/プラグインの SKILL.md frontmatter から `{name, description}` を抽出、(3) サブエージェント: user/project/プラグインの agent .md frontmatter から抽出、(4) claude.ai baseline: OAuth proxy 経由の Gmail/Calendar/Drive 25 件は手書き baseline で補完。**手書きでツールリストを管理する必要はありません**。
48
48
 
49
49
  ## Throughline との関係
50
50
 
@@ -62,8 +62,9 @@ Stop hook → Spotter が応答と使用済みツールを見て最終チェッ
62
62
 
63
63
  ```bash
64
64
  spotter db list # 現在の tool-db (local + global merged) を表示
65
- spotter db refresh # MCP サーバーと組込み 遅延ツールから description を収集して DB 更新
66
- spotter db rebuild # ローカル DB を消してから refresh (強制再投資)
65
+ spotter db refresh # MCP / スキル / サブエージェントから description を収集して DB 更新
66
+ # (v1.1.0 以降、install 時と SessionStart 時に自動実行されるので通常は不要)
67
+ spotter db rebuild # local + global DB を両方消してから refresh (カタログ設計変更時のクリーン用)
67
68
  spotter status # 稼働中の daemon 一覧
68
69
  spotter doctor # 環境診断 (Node / claude CLI / tool-db 整合性)
69
70
  spotter uninstall # hook 登録を解除 (~/.spotter は残す)
@@ -71,12 +72,15 @@ spotter uninstall # hook 登録を解除 (~/.spotter は残す)
71
72
 
72
73
  ## 設計ドキュメント
73
74
 
74
- 全ての設計判断 透明化 vs 不可視化、JSON I/O、socket 抽象、メッセージ契約、SessionStart の readiness 戦略、§0 実装規範 — は [docs/spotter-plan.md](docs/spotter-plan.md) に記載しています。**実装を変更する前に必ず参照してください。**
75
+ - **現行設計 (カタログ / 収集経路 / 分類軸)**: [docs/catalog-design.md](docs/catalog-design.md) — v1.0.0 以降の真実源
76
+ - **現時点で塞がっていない穴 + 実測未検証の懸念**: [docs/open-issues.md](docs/open-issues.md) — 新規作業に入る前に必読
77
+ - **実装規範と不変条件 (§0)**: [CLAUDE.md](CLAUDE.md) — フォールバック禁止 / silent fallback 禁止 / 暫定コード禁止
78
+ - **歴史記録 (v0.1 時点の設計議事録)**: [docs/spotter-plan.md](docs/spotter-plan.md) — 作成時点で固定された議論過程のスナップショット、現行設計は上記 3 点を参照
75
79
 
76
80
  ## 既知の制約
77
81
 
78
82
  - Stop hook は Bell の最初の応答が**出力された後**に発火するため、Spotter が Stop で差し戻した場合、ユーザーは「最初の応答 + 補正応答」の 2 連続を見ます (Claude Code の hook 仕様による制約)。UserPromptSubmit 段階での先回り検出を精度の軸にしています
79
- - **JSON スキーマ違反は v0.5.0 以降「想定済み異常」として silent pass + session renew で回復**します (role collapse 検知パス、daemon ログに `role_collapse_reset` を残す)。一方 **Haiku timeout は引き続き throw** され、UserPromptSubmit がブロックされてユーザー入力が Bell に届かない症状として顕在化します (timeout は v0.5.0 で 30s に短縮)。timeout の fail-open 化 (pass 扱い) は §0 改訂とセットで今後検討
83
+ - **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 改訂とセットで今後検討
80
84
 
81
85
  ## ライセンス
82
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,12 +75,19 @@ const DRIVE = {
75
75
  'List who has access to a Drive file and their role (reader/commenter/writer/owner). Use when the user asks who can see or edit a file.',
76
76
  };
77
77
 
78
- const ALL = { ...GMAIL, ...CALENDAR, ...DRIVE };
79
-
80
- export function listClaudeAiNames() {
81
- return Object.keys(ALL);
82
- }
78
+ // Server-level structure. Keys are the literal server names as reported by
79
+ // `claude mcp list` — the callers in refresh.mjs filter by this name so that the
80
+ // baseline is only injected for servers actually visible in the current session.
81
+ // Without this filter, 25 phantom tools leak into the catalog in any environment
82
+ // where Claude.ai OAuth is not connected (isolated CLAUDE_CONFIG_DIR, Max plan
83
+ // users without Desktop integration, or partial Gmail-only / Calendar-only setups).
84
+ const BY_SERVER = {
85
+ 'claude.ai Gmail': GMAIL,
86
+ 'claude.ai Google Calendar': CALENDAR,
87
+ 'claude.ai Google Drive': DRIVE,
88
+ };
83
89
 
84
- export function getClaudeAiDescription(name) {
85
- return ALL[name] ?? null;
90
+ // Returns Map<serverName, {toolName: description}>.
91
+ export function getClaudeAiBaselineByServer() {
92
+ return new Map(Object.entries(BY_SERVER));
86
93
  }
@@ -45,7 +45,7 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude',
45
45
  const out = new Map();
46
46
  for (const server of servers) {
47
47
  try {
48
- const tools = await listMcpToolsOne({ server, logFn });
48
+ const tools = await listMcpToolsOne({ server, logFn, claudeBin, projectRoot });
49
49
  out.set(server.name, tools);
50
50
  } catch (err) {
51
51
  logFn(`mcp investigate failed for "${server.name}": ${err.message}`);
@@ -64,9 +64,15 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude',
64
64
  // full descriptor (with env/headers). Otherwise we fall back to the parsed CLI line,
65
65
  // which at minimum gives us name + transport + url (or triggers `claude mcp get` for
66
66
  // stdio command tokenisation).
67
+ //
68
+ // We pass `cwd: projectRoot` to the CLI so its project-scope walk-up lands in the same
69
+ // directory we read `.mcp.json` from. Without this, `claude` walks up from the parent
70
+ // process's cwd and can resolve a different project than `readMcpServers` does.
67
71
  export async function listMcpServers({ claudeBin = 'claude', projectRoot } = {}) {
72
+ const execOpts = { encoding: 'utf8' };
73
+ if (projectRoot) execOpts.cwd = projectRoot;
68
74
  const [{ stdout }, mcpServers] = await Promise.all([
69
- execClaude(claudeBin, ['mcp', 'list'], { encoding: 'utf8' }),
75
+ execClaude(claudeBin, ['mcp', 'list'], execOpts),
70
76
  readMcpServers({ projectRoot }),
71
77
  ]);
72
78
  const cliList = parseMcpListOutput(stdout);
@@ -115,12 +121,12 @@ export function parseMcpListOutput(text) {
115
121
  // Fetch tools/list from a single MCP server. The `server` descriptor either came
116
122
  // from `.mcp.json` (carries env / headers) or from CLI output (bare). For stdio
117
123
  // entries without full config we fall back to `claude mcp get`.
118
- export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude' }) {
124
+ export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude', projectRoot }) {
119
125
  if (server.transport === 'stdio') {
120
126
  const hasFullConfig = server.command !== undefined;
121
127
  const config = hasFullConfig
122
128
  ? { command: server.command, args: server.args ?? [], env: server.env ?? {} }
123
- : await getStdioConfig({ name: server.name, claudeBin });
129
+ : await getStdioConfig({ name: server.name, claudeBin, projectRoot });
124
130
  return spawnAndQuery(config, server.name);
125
131
  }
126
132
  if (server.transport === 'http' || server.transport === 'sse') {
@@ -135,8 +141,12 @@ export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'c
135
141
  }
136
142
 
137
143
  // Parse `claude mcp get <name>` to extract Command + Args for stdio servers.
138
- async function getStdioConfig({ name, claudeBin }) {
139
- const { stdout } = await execClaude(claudeBin, ['mcp', 'get', name], { encoding: 'utf8' });
144
+ // `cwd: projectRoot` pins the CLI's scope walk-up to the same directory used for
145
+ // `.mcp.json` reading see listMcpServers for the rationale.
146
+ async function getStdioConfig({ name, claudeBin, projectRoot }) {
147
+ const execOpts = { encoding: 'utf8' };
148
+ if (projectRoot) execOpts.cwd = projectRoot;
149
+ const { stdout } = await execClaude(claudeBin, ['mcp', 'get', name], execOpts);
140
150
  let command = null;
141
151
  let argsRaw = null;
142
152
  for (const rawLine of stdout.split('\n')) {
@@ -11,12 +11,26 @@
11
11
  // sub-agents.
12
12
 
13
13
  import { resolveAll } from './lookup.mjs';
14
- import { listMcpToolsAll, bellVisibleName } from './investigate-mcp.mjs';
15
- import { getClaudeAiDescription, listClaudeAiNames } from './claude-ai-baseline.mjs';
14
+ import { listMcpToolsAll, listMcpServers, bellVisibleName } from './investigate-mcp.mjs';
15
+ import { getClaudeAiBaselineByServer } from './claude-ai-baseline.mjs';
16
16
  import { listSkillsAll } from './investigate-skills.mjs';
17
17
  import { listAgentsAll } from './investigate-agents.mjs';
18
18
  import { localDbPath, globalDbPath } from './loader.mjs';
19
19
 
20
+ // Pure filter: returns the subset of the claude.ai baseline whose server name is
21
+ // present in `presentServerNames`. Accepts a Set for O(1) membership. Extracted as
22
+ // a named export so it can be unit-tested without a live `claude` CLI.
23
+ export function filterClaudeAiBaseline(presentServerNames) {
24
+ const out = new Map();
25
+ for (const [serverName, tools] of getClaudeAiBaselineByServer()) {
26
+ if (!presentServerNames.has(serverName)) continue;
27
+ for (const [toolName, description] of Object.entries(tools)) {
28
+ out.set(toolName, description);
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
20
34
  // Build the (name → description) map for an investigation pass across all sources:
21
35
  // - claude.ai MCP baseline (Gmail / Calendar / Drive — OAuth, not locally introspectable)
22
36
  // - MCP servers via stdio + HTTP/SSE (user + project .mcp.json, live fetched)
@@ -27,11 +41,19 @@ import { localDbPath, globalDbPath } from './loader.mjs';
27
41
  export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
28
42
  const snapshot = new Map();
29
43
 
30
- // Anthropic-provided `claude.ai ...` MCP servers hardcoded because the OAuth proxy
44
+ // Anthropic-provided `claude.ai ...` MCP servers. Hardcoded because the OAuth proxy
31
45
  // is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
32
- // If a live HTTP investigate for the same name later succeeds below, it overrides.
33
- for (const name of listClaudeAiNames()) {
34
- snapshot.set(name, getClaudeAiDescription(name));
46
+ // Injected only for servers actually present in `claude mcp list` otherwise phantom
47
+ // tools (Gmail/Calendar/Drive) leak into environments where those servers are not
48
+ // connected. See filterClaudeAiBaseline above.
49
+ const servers = await listMcpServers({ claudeBin, projectRoot });
50
+ const presentServerNames = new Set(servers.map((s) => s.name));
51
+ const baseline = filterClaudeAiBaseline(presentServerNames);
52
+ for (const [name, description] of baseline) {
53
+ snapshot.set(name, description);
54
+ }
55
+ if (baseline.size > 0) {
56
+ logFn(`claude.ai baseline injected: ${baseline.size} tools from ${[...presentServerNames].filter((n) => n.startsWith('claude.ai ')).join(', ')}`);
35
57
  }
36
58
 
37
59
  // MCP servers (stdio + user-registered HTTP/SSE). projectRoot forwards for