claude-spotter 1.1.3 → 1.1.5

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,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.5
4
+
5
+ **Windows で refresh 毎に cmd.exe console window が flash + 入力フォーカスを奪う UX 回帰を修正**。`listMcpServers` / `getStdioConfig` が `execClaude` 経由で spawn する `cmd.exe /c claude mcp list/get` に `windowsHide: true` が付いておらず、SessionStart 毎の bg refresh と install 時 seed で毎回黒いウィンドウが一瞬表示されキーボード入力が奪われていた。
6
+
7
+ ### 変更点
8
+
9
+ - **編集 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)**: `execClaude` ヘルパ内で `opts` を spread した上で `windowsHide: true` を強制。呼び出し側 (`listMcpServers`, `getStdioConfig`) の `execOpts` に毎回書かせるのではなく、helper 層で固定することで将来の call site も自動で守られる。
10
+
11
+ ### 背景
12
+
13
+ Windows の `spawn` / `execFile` は `windowsHide` オプションが `false` のとき、child process の console window を visible で起動する。Spotter の spawn サイトは 6 箇所 (daemon spawn / refresh detached / haiku-caller / MCP stdio spawn / doctor / execClaude) あり、うち 5 箇所は個別に `windowsHide: true` を付けていたが、`execClaude` だけ opts 任せになっていて pass されていなかった。
14
+
15
+ SessionStart 毎の `spotter db refresh` で `listMcpServers` が 1 回、stdio MCP サーバーの数だけ `getStdioConfig` が呼ばれるため、MCP サーバー N 個の環境では SessionStart 毎に **1 + N 回** の flash が発生。加えて `spotter install` 時の seed でも同じ経路を通る。体感「結構な頻度で入力を奪われる」という UX 回帰の直接原因。
16
+
17
+ ### 設計判断
18
+
19
+ - **helper 層で windowsHide 強制**: call site 毎に書かせる方針は 2 箇所の execOpts を更新するだけで済むが、新 call site 追加時に忘れるリスクが残る。`execClaude` は外部コマンド (`claude` CLI) 専用で Windows では常に cmd.exe 経由のため、「このヘルパ経由なら silent」という不変条件を layer 内で閉じた方が防御堅牢。
20
+ - **他 5 spawn サイトの監査**: `spawn-daemon.mjs` (daemon + refresh detached), `haiku-caller.mjs` (claude -p), `investigate-mcp.mjs:spawnAndQuery` (MCP stdio), `doctor.mjs` (claude --version) はすべて `windowsHide: true` 済みを確認。この修正で残る穴はゼロ。
21
+ - **テスト追加なし**: Windows console window visibility は cross-platform ユニットテストで検証しづらい (Windows 環境でも Node の test runner 経由で spawn した child の visibility を assert する API がない)。監査対象は 6 spawn サイト全件の源コード上の `windowsHide: true` の存在のみ、これは grep で機械検証できる。
22
+
23
+ ## 1.1.4
24
+
25
+ **MCP 投資ロジックの 2 件の穴を修正**。どちらも「名乗っているスコープ」と「実際に参照されるスコープ」が一致していない silent mismatch。前者は projectRoot 引数が効かない経路、後者は baseline が現実を無視して常に 25 件投入される経路。
26
+
27
+ ### 変更点
28
+
29
+ - **編集 [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 の意味論を実装に揃える
30
+ - **編集 [src/tool-db/claude-ai-baseline.mjs](src/tool-db/claude-ai-baseline.mjs)**: flat な `listClaudeAiNames` / `getClaudeAiDescription` を削除、server 単位の `getClaudeAiBaselineByServer()` に再編。Gmail / Calendar / Drive を個別集合として保持し、呼び出し側で現実に存在するサーバーのみ注入できるようにした
31
+ - **編集 [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 として切り出しテスト可能にした
32
+ - **編集 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: `filterClaudeAiBaseline` の回帰テスト 3 件追加 — 全 3 サーバー存在 / Gmail のみ存在 / 全不在
33
+
34
+ ### 背景
35
+
36
+ #### projectRoot の silent mismatch
37
+
38
+ 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 が乖離する可能性が残っていた。
39
+
40
+ #### claude.ai baseline の無条件注入
41
+
42
+ 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 が推奨する誤検出源になっていた。
43
+
44
+ ### 設計判断
45
+
46
+ - **`listMcpToolsAll` のシグネチャは触らない**: baseline filter 用に `listMcpServers` を buildInvestigationSnapshot で先に呼ぶと、内部で listMcpToolsAll がもう一度 CLI spawn する。pre-resolved servers 引数で避けられるが、API 表面を増やすコストに対し `claude mcp list` は 0.5-2s の 1 度だけなので受容
47
+ - **診断ログ追加**: baseline 注入時に `claude.ai baseline injected: N tools from <server list>` を logFn に出力。どの環境で何件入ったか後から追えるようにした
48
+ - **後方互換 export は削除**: `listClaudeAiNames` / `getClaudeAiDescription` は [src/index.mjs](src/index.mjs) に re-export されておらず、外部利用の形跡なし。残しても drift 源になるため削除
49
+
3
50
  ## 1.1.3
4
51
 
5
52
  **v1.1.x の実装進展にドキュメントを追従させる docs-only リリース**。コード変更なし。npm package tarball 同梱の README が古い手順を指していたため再 publish。
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Spotter
2
2
 
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)。
3
+ > **v1.1.4 released 2026-04-20**. **MCP 投資経路の 2 件の silent mismatch を修正**。(1) `claude mcp list / get` spawn 時の `cwd: projectRoot` 未指定、(2) claude.ai baseline (Gmail/Calendar/Drive 25 件) の無条件注入 — `claude mcp list` の実在確認を入れ、隔離 `CLAUDE_CONFIG_DIR` / 未連携環境で最大 25 件の幻ツールが catalog に残っていた状態を解消 (Bell 側実環境で 25 件消失を実測確認済み)。v1.1.0 からの柱 (install 時 tool-db 自動構築 + SessionStart での drift 自動追従) は継続、手動 `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` (ローカル) に格納されます。**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 で補完。**手書きでツールリストを管理する必要はありません**。
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 で補完 (v1.1.4 以降、`claude mcp list` に該当サーバーが存在する環境でのみ注入)。**手書きでツールリストを管理する必要はありません**。
48
48
 
49
49
  ## Throughline との関係
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
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
  }
@@ -23,11 +23,15 @@ const HANDSHAKE_TIMEOUT_MS = 10_000;
23
23
  // On Windows, `claude` is a .cmd shim; Node's execFile cannot locate it directly without
24
24
  // going through cmd.exe. Matches the pattern in src/daemon/haiku-caller.mjs buildSpawnArgs.
25
25
  // We use cmd.exe /c rather than shell:true to avoid DEP0190 on Node 24+.
26
+ // `windowsHide: true` is forced at this layer so every caller (listMcpServers,
27
+ // getStdioConfig, etc.) is silent — without it a cmd.exe console window flashes on every
28
+ // refresh, and those flashes steal keyboard focus on Windows.
26
29
  async function execClaude(claudeBin, args, opts) {
30
+ const execOpts = { ...opts, windowsHide: true };
27
31
  if (process.platform === 'win32') {
28
- return execFileP('cmd.exe', ['/c', claudeBin, ...args], opts);
32
+ return execFileP('cmd.exe', ['/c', claudeBin, ...args], execOpts);
29
33
  }
30
- return execFileP(claudeBin, args, opts);
34
+ return execFileP(claudeBin, args, execOpts);
31
35
  }
32
36
 
33
37
  export class McpInvestigationError extends Error {
@@ -45,7 +49,7 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude',
45
49
  const out = new Map();
46
50
  for (const server of servers) {
47
51
  try {
48
- const tools = await listMcpToolsOne({ server, logFn });
52
+ const tools = await listMcpToolsOne({ server, logFn, claudeBin, projectRoot });
49
53
  out.set(server.name, tools);
50
54
  } catch (err) {
51
55
  logFn(`mcp investigate failed for "${server.name}": ${err.message}`);
@@ -64,9 +68,15 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude',
64
68
  // full descriptor (with env/headers). Otherwise we fall back to the parsed CLI line,
65
69
  // which at minimum gives us name + transport + url (or triggers `claude mcp get` for
66
70
  // stdio command tokenisation).
71
+ //
72
+ // We pass `cwd: projectRoot` to the CLI so its project-scope walk-up lands in the same
73
+ // directory we read `.mcp.json` from. Without this, `claude` walks up from the parent
74
+ // process's cwd and can resolve a different project than `readMcpServers` does.
67
75
  export async function listMcpServers({ claudeBin = 'claude', projectRoot } = {}) {
76
+ const execOpts = { encoding: 'utf8' };
77
+ if (projectRoot) execOpts.cwd = projectRoot;
68
78
  const [{ stdout }, mcpServers] = await Promise.all([
69
- execClaude(claudeBin, ['mcp', 'list'], { encoding: 'utf8' }),
79
+ execClaude(claudeBin, ['mcp', 'list'], execOpts),
70
80
  readMcpServers({ projectRoot }),
71
81
  ]);
72
82
  const cliList = parseMcpListOutput(stdout);
@@ -115,12 +125,12 @@ export function parseMcpListOutput(text) {
115
125
  // Fetch tools/list from a single MCP server. The `server` descriptor either came
116
126
  // from `.mcp.json` (carries env / headers) or from CLI output (bare). For stdio
117
127
  // entries without full config we fall back to `claude mcp get`.
118
- export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude' }) {
128
+ export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude', projectRoot }) {
119
129
  if (server.transport === 'stdio') {
120
130
  const hasFullConfig = server.command !== undefined;
121
131
  const config = hasFullConfig
122
132
  ? { command: server.command, args: server.args ?? [], env: server.env ?? {} }
123
- : await getStdioConfig({ name: server.name, claudeBin });
133
+ : await getStdioConfig({ name: server.name, claudeBin, projectRoot });
124
134
  return spawnAndQuery(config, server.name);
125
135
  }
126
136
  if (server.transport === 'http' || server.transport === 'sse') {
@@ -135,8 +145,12 @@ export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'c
135
145
  }
136
146
 
137
147
  // 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' });
148
+ // `cwd: projectRoot` pins the CLI's scope walk-up to the same directory used for
149
+ // `.mcp.json` reading — see listMcpServers for the rationale.
150
+ async function getStdioConfig({ name, claudeBin, projectRoot }) {
151
+ const execOpts = { encoding: 'utf8' };
152
+ if (projectRoot) execOpts.cwd = projectRoot;
153
+ const { stdout } = await execClaude(claudeBin, ['mcp', 'get', name], execOpts);
140
154
  let command = null;
141
155
  let argsRaw = null;
142
156
  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