claude-spotter 0.13.3 → 1.1.0

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,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ **`spotter install` が tool-db を自動構築 + SessionStart hook がバックグラウンド refresh**。install 直後から audit 対象が揃うようになり、以降の session でも MCP / スキル / サブエージェントの追加・削除が自動追従する。
6
+
7
+ ### 背景
8
+
9
+ v1.0.0 以前は `spotter install` が hook 登録だけで tool-db を作らず、別途 `spotter db refresh` を手動実行する必要があった。初回セッションで daemon が空 DB を掴むと Haiku に preamble が届かず audit が機能しない状態で起動してしまう。また、install 後に MCP や スキルを追加しても rebuild/refresh を手動で叩くまで視野に入らず、drift が常に発生していた。
10
+
11
+ ### 変更点
12
+
13
+ - **編集 [src/cli/install.mjs](src/cli/install.mjs)**: settings.json 書き込み後に project-mode で `refresh({projectRoot})` を同期実行し tool-db を seed。失敗時は §0 準拠で throw (hook だけ登録されて DB が無い中途半端な状態を残さない)。`skipRefresh` オプションを新設 (既存テストが user 環境をスキャンしないように)。"next steps" メッセージから `spotter db refresh` の手動実行指示を削除
14
+ - **編集 [src/hooks/session-start.mjs](src/hooks/session-start.mjs)**: daemon readiness 確立後に `spawnRefreshDetached({projectRoot})` を発火。hook 自体は即 return、refresh は detached child として bg 実行。**現セッションの daemon は起動時の tool-db で固定**のため反映は次セッション以降
15
+ - **編集 [src/hooks/spawn-daemon.mjs](src/hooks/spawn-daemon.mjs)**: `spawnRefreshDetached` を追加 export。`node <spotterBin> db refresh` を `detached: true, stdio: 'ignore', unref()` で起動、hook を遅延させない
16
+ - **テスト更新 [test/install.test.mjs](test/install.test.mjs)**: 全 6 件が `skipRefresh: true` を渡すよう更新
17
+
18
+ ### 設計判断
19
+
20
+ - **rebuild ではなく refresh を採用**: 当初 user 指示は rebuild (local+global wipe + 全再スキャン) だったが、(1) 既適用プロジェクトの global キャッシュを毎 SessionStart で破壊するのは副作用が大きい、(2) 並列セッション (Project A rebuild 中に Project B SessionStart) で書き込み競合が発生する、の 2 点から refresh に変更。差分更新でも新規・削除の drift 追従は効く。description drift (同一名の description 更新) のみ取りこぼすが、これは `spotter db rebuild` の手動実行でカバー
21
+ - **SessionStart の refresh は bg detached**: session-start hook の timeout は 5s で、MCP 全サーバー spawn + skills 181 件スキャンは秒単位かかるため同期実行は不可能。detached + unref で hook を遅延させない代わりに、反映は次セッション以降 (現セッションの daemon は既に古い tool-db をロード済みで、実行中の差し替えはしない)
22
+ - **install 時の refresh 失敗は throw**: "hook 登録済みだが DB なし" という中途半端な状態を残すくらいなら install 自体を失敗扱いにするほうがクリーン。再試行は `spotter install` の再実行で、hook 登録は `nothing to change` で skip され refresh だけ走る
23
+
24
+ ### 破壊変更
25
+
26
+ なし (skipRefresh オプションはデフォルト false で既存挙動より機能追加、CLI 利用者には透過)。
27
+
28
+ ### 影響範囲
29
+
30
+ - 新規 `spotter install` 実行時は MCP/skills/agents の discover でセットアップ時間が数秒〜10 秒増える
31
+ - 毎 SessionStart でバックグラウンド `spotter db refresh` プロセスが 1 つ発火 (bg unref なので UX 影響なし)
32
+ - global tool-db への書き込みが session 起動ごとに発生 (atomic write なので corruption リスクなし、last-write-wins の並列 race は idempotent なので次 refresh で収束)
33
+
34
+ ## 1.0.0
35
+
36
+ **監査対象をユーザー追加分 (MCP / スキル / サブエージェント) に絞り込み**。Claude Code 本体が提供するツール (即時 + 遅延) は監査カタログから全面除外。設計転換の major bump。
37
+
38
+ ### 背景
39
+
40
+ v0.13.x までは「Claude Code 組込みの遅延ツール (WebSearch / TodoWrite 等 17 件) は Bell が呼び忘れやすい」という仮定で手書き baseline を保持していた。今回の設計会議で 2 点が判明し、前提自体を撤回:
41
+
42
+ 1. **Bell は本体側ツールを使いこなしている**。WebSearch / WebFetch / NotebookEdit / Cron 系 / Worktree / 通知は自発率が十分高く、Spotter が提案すべき呼び忘れ対象ではない
43
+ 2. **即時 / 遅延の境界は Claude Code バージョンで動的に変わる**。現セッションの実測で、`AskUserQuestion` / `TodoWrite` / `EnterPlanMode` / `ExitPlanMode` / `TaskOutput` / `TaskStop` は baseline に「遅延」と書かれているが実際には **即時ツール**として扱われていた。手書き baseline は構造的に drift するので追従は不可能
44
+
45
+ Spotter の役割は「Bell にとって**言われないと思い出さない** MCP / スキル / サブエージェントを視野に入れさせる」こと。本体側は Bell の手中にある。
46
+
47
+ ### 変更点
48
+
49
+ - **削除 [src/tool-db/deferred-baseline.mjs](src/tool-db/deferred-baseline.mjs)**: 手書き 17 件の baseline を撤去。`DEFERRED_TOOL_BASELINE` / `getDeferredDescription` / `listDeferredNames` の export も削除 (破壊変更)
50
+ - **新規 [src/tool-db/frontmatter.mjs](src/tool-db/frontmatter.mjs)**: SKILL.md / agent .md の YAML frontmatter から `name` + `description` を抽出する最小パーサー (ゼロ依存)
51
+ - **新規 [src/tool-db/investigate-skills.mjs](src/tool-db/investigate-skills.mjs)**: スキルを 3 scope (user / project / 有効化プラグイン) から収集。プラグイン由来は `<plugin>:<skill>` に名前空間化、ユーザー / プロジェクト由来は素の名前。`enabledPlugins` の有効化判定は user scope + project scope の union、`~/.claude/plugins/installed_plugins.json` の `installPath` から実体にアクセス
52
+ - **新規 [src/tool-db/investigate-agents.mjs](src/tool-db/investigate-agents.mjs)**: サブエージェントを同じく 3 scope から収集。名前は素の名前、衝突は project > user > plugin の優先順で解決
53
+ - **編集 [src/tool-db/refresh.mjs](src/tool-db/refresh.mjs)**: `buildInvestigationSnapshot` から deferred 経路を削除、スキル / サブエージェント経路を追加。MCP live fetch + `claude.ai` baseline (Gmail / Calendar / Drive) は維持
54
+ - **編集 [src/cli/db-cmd.mjs](src/cli/db-cmd.mjs)**: `spotter db rebuild` が local DB に加えて **global DB も wipe** するように仕様変更。旧バージョンから上がってきたユーザーが古い deferred エントリを抱えたままにならないため
55
+ - **編集 [src/index.mjs](src/index.mjs)**: `listSkillsAll` / `listActivePlugins` / `listAgentsAll` の export 追加
56
+ - **リネーム [docs/catalog-design-deferred-mcp.md](docs/catalog-design.md) → [docs/catalog-design.md](docs/catalog-design.md)**: 大幅書き直し。対象範囲・分類軸・収集経路を v1.0.0 仕様で更新
57
+ - **テスト更新 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: deferred baseline テスト 3 件削除、frontmatter テスト 3 件 + skill テスト 2 件 + agent テスト 2 件を追加。計 32 件全通過
58
+
59
+ ### 結果
60
+
61
+ 本プロジェクトで `buildInvestigationSnapshot` を走らせると **268 件 resolved**:
62
+
63
+ - MCP: 40 件 (caveat 6 + claude.ai Gmail/Calendar/Drive 25 + x-api 9)
64
+ - スキル (名前空間付き): 181 件 (ECC が大半)
65
+ - サブエージェント + bare スキル: 47 件 (ECC 38 agents + 他)
66
+
67
+ preamble 初回送信サイズ推定 15-25K tokens (Haiku 4.5 の 200K コンテキストに対し 12% 程度)。v0.6.0 preamble-once で 2 回目以降は per-turn delta のみ、セッション 1 回あたりの追加コストは初回のみ。
68
+
69
+ ### 設計判断
70
+
71
+ - **ECC プラグインの全スキル (181 件) をそのまま投入**: 「使う可能性があるなら視野に入れる」が Spotter の本旨。description の semantic 判定は Haiku に任せる (preamble 肥大より recall 優先)
72
+ - **learned/ などの空ディレクトリは silent に素通り**: SKILL.md が無ければスキルではない、ENOENT だけログを抑制
73
+ - **名前衝突の解決**: スキルは `<plugin>:<name>` で namespace 分離されるので衝突せず。サブエージェントは bare name なので project > user > plugin 優先で Map 上書き
74
+ - **破壊変更を major bump で明示**: カタログ契約が変わる + 公開 export 削除 + db rebuild 仕様変更、の 3 点で semver major
75
+
76
+ ### 残課題
77
+
78
+ - **初回 Haiku latency の観測**: 268 件 preamble で `duration_ms` が 45s timeout に接近しないか実運用で確認。超えるようなら再緩和か件数絞り込みを再検討
79
+ - **baseline 自動追従機構**: `claude.ai` MCP baseline は手書きのまま。Claude 側で追加があっても検知できない。長期的には監視機構が必要 ([docs/open-issues.md](docs/open-issues.md) P1 継続)
80
+ - **v0.13.0 新軸の過検出**: turn_end 軸 (ツール適用機会監査) の誤爆パターンは別問題、継続観測
81
+
3
82
  ## 0.13.3
4
83
 
5
84
  **カタログ外ツール名の推奨を遮断 (prompt 明示 + 事後 filter の二重防御)**。v0.13.2 リリース直後の実セッション ([daemon-f047521c.log](../../.spotter/logs/daemon-f047521c-9cce-4822-9555-90b206b8341e.log) line 9) で `turn_end: pass=false, missing=Skill(tl)` を観測。**`Skill(tl)` はカタログ (tool-db.json 57 件) に存在しない**。Haiku が training 記憶 or few-shot の `current_time` / `Skill` 表記から cargo-cult してカタログ外名を提案していた。これが恒常化するとユーザーが無効な推奨に混乱する + /tl など description を直しても Haiku は参照していないため修正が届かない、という構造問題になる。
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Spotter
2
2
 
3
- > **v0.7.0 released 2026-04-19**. **カタログを tool-db に置き換え**: 手書きの抽象ツール 5 件カタログを廃止し、`claude mcp list` + JSON-RPC `tools/list` MCP ツールの description を自動取得、Claude Code 組込み 遅延ツール (WebSearch/TodoWrite 等) hardcoded baseline からカバー、3 段階キャッシュ DB (ローカル グローバル 調査して両方に追記) 2 回目以降は通信ゼロ。これで Caveat Gmail のような MCP ツールが初めて Haiku の視野に入る。設計思想は [docs/catalog-design-deferred-mcp.md](docs/catalog-design-deferred-mcp.md)、変更詳細は [CHANGELOG](CHANGELOG.md)。
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)。
4
4
 
5
5
  **気づく役と実行する役を分離する。** Spotter は Claude Code の横で静かに並走し、Bell (主役の Claude) が**ツールを呼び忘れたとき**に指摘する監査役です。
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.13.3",
3
+ "version": "1.1.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,7 @@ export async function runDbList() {
35
35
  export async function runDbRefresh() {
36
36
  const projectRoot = requireProjectRoot();
37
37
  const log = (msg) => process.stderr.write(`spotter db refresh: ${msg}\n`);
38
- log('discovering MCP servers and built-in deferred tools...');
38
+ log('discovering MCP servers, skills, and sub-agents...');
39
39
  const resolved = await refresh({ projectRoot, logFn: log });
40
40
  const counts = { local: 0, global: 0, investigated: 0 };
41
41
  for (const { source } of resolved.values()) counts[source] = (counts[source] ?? 0) + 1;
@@ -48,8 +48,12 @@ export async function runDbRefresh() {
48
48
 
49
49
  export async function runDbRebuild() {
50
50
  const projectRoot = requireProjectRoot();
51
- // Wipe local DB so every tool's resolution falls through to global investigate.
51
+ // v1.0.0: wipe BOTH local and global DB. Rationale: the catalog scope changed in
52
+ // v1.0.0 (Claude Code built-ins removed; skills + sub-agents added). Stale entries
53
+ // from older versions would otherwise linger in the global DB since `refresh` only
54
+ // touches names currently produced by investigation. Users need a clean slate.
52
55
  await saveDb(localDbPath(projectRoot), emptyDb());
53
- process.stderr.write(`spotter db rebuild: cleared local DB, refreshing...\n`);
56
+ await saveDb(globalDbPath(), emptyDb());
57
+ process.stderr.write(`spotter db rebuild: cleared local + global DB, refreshing...\n`);
54
58
  await runDbRefresh();
55
59
  }
@@ -7,8 +7,9 @@
7
7
  // silently exit otherwise (prevents Throughline-style proliferation).
8
8
  //
9
9
  // v0.7.0: tool catalog (the old YAML) is replaced by tool-db.json (auto-discovered MCP
10
- // + hardcoded built-in deferred). Install no longer seeds a template — user runs
11
- // `spotter db refresh` after install to populate.
10
+ // servers, skills, sub-agents see docs/catalog-design.md for v1.0.0 scope).
11
+ // Install seeds the DB automatically via `refresh` (project-mode only user-mode
12
+ // has no projectRoot so DB seeding is skipped there).
12
13
 
13
14
  import { mkdir, writeFile, readFile, access } from 'node:fs/promises';
14
15
  import { homedir } from 'node:os';
@@ -16,6 +17,8 @@ import { join, resolve, dirname } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import { createInterface } from 'node:readline/promises';
18
19
  import { version as SPOTTER_VERSION } from '../version.mjs';
20
+ import { refresh } from '../tool-db/refresh.mjs';
21
+ import { localDbPath, globalDbPath } from '../tool-db/loader.mjs';
19
22
 
20
23
  const HERE = dirname(fileURLToPath(import.meta.url));
21
24
  const PACKAGE_ROOT = resolve(HERE, '..', '..');
@@ -33,7 +36,7 @@ const HOOK_EVENTS = [
33
36
  { event: 'SessionEnd', sub: 'session-end', timeout: 3 },
34
37
  ];
35
38
 
36
- export async function runInstall({ target = 'project', autoYes = false, cwd = process.cwd() } = {}) {
39
+ export async function runInstall({ target = 'project', autoYes = false, cwd = process.cwd(), skipRefresh = false } = {}) {
37
40
  const settingsPath = target === 'user'
38
41
  ? join(homedir(), '.claude', 'settings.json')
39
42
  : join(cwd, '.claude', 'settings.json');
@@ -96,9 +99,21 @@ export async function runInstall({ target = 'project', autoYes = false, cwd = pr
96
99
  await mkdir(dirname(settingsPath), { recursive: true });
97
100
  await writeFile(settingsPath, JSON.stringify(updated, null, 2) + '\n', 'utf8');
98
101
  console.log(`wrote ${settingsPath}`);
102
+
103
+ // Seed the tool-db so the first session has something to audit against.
104
+ // Skipped for user-mode (deprecated — no projectRoot) and when caller opts out
105
+ // (tests set skipRefresh=true to avoid scanning the real user environment).
106
+ if (target === 'project' && !skipRefresh) {
107
+ console.log('\ndiscovering MCP servers, skills, and sub-agents...');
108
+ const log = (msg) => process.stderr.write(` ${msg}\n`);
109
+ const resolved = await refresh({ projectRoot: cwd, logFn: log });
110
+ console.log(` ${resolved.size} tool(s) resolved`);
111
+ console.log(` local DB: ${localDbPath(cwd)}`);
112
+ console.log(` global DB: ${globalDbPath()}`);
113
+ }
114
+
99
115
  console.log('\nnext steps:');
100
- console.log(' 1. run `spotter db refresh` to discover available MCP/deferred tools');
101
- console.log(' 2. reload Claude Code (or open a new session) to activate Spotter');
116
+ console.log(' reload Claude Code (or open a new session) to activate Spotter');
102
117
  }
103
118
 
104
119
  async function exists(path) {
@@ -17,7 +17,7 @@
17
17
  // now heartbeat-based inside the daemon.
18
18
 
19
19
  import { readStdinJson, requireString, die, isChildCall, isSubagentCall, isOutsideSpotterProject, findSpotterMarker } from './lib.mjs';
20
- import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
20
+ import { spawnDaemonAndWaitReady, spawnRefreshDetached } from './spawn-daemon.mjs';
21
21
 
22
22
  export async function runSessionStart({ now = Date.now } = {}) {
23
23
  if (isChildCall()) return;
@@ -35,6 +35,7 @@ export async function runSessionStart({ now = Date.now } = {}) {
35
35
  }
36
36
 
37
37
  await spawnDaemonAndWaitReady({ sessionId, projectRoot, now });
38
+ spawnRefreshDetached({ projectRoot });
38
39
  }
39
40
 
40
41
  if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
@@ -66,6 +66,27 @@ function spawnDaemon(sessionId, projectRoot) {
66
66
  child.unref();
67
67
  }
68
68
 
69
+ // Fire-and-forget `spotter db refresh`. The daemon in THIS session already loaded the
70
+ // pre-refresh tool-db, so any updates land in time for the NEXT session. Detached so
71
+ // the hook doesn't wait for MCP/skill/agent discovery to finish.
72
+ export function spawnRefreshDetached({ projectRoot }) {
73
+ const spotterBin = resolveSpotterBin();
74
+ const child = spawn(
75
+ process.execPath,
76
+ [spotterBin, 'db', 'refresh'],
77
+ {
78
+ cwd: projectRoot,
79
+ detached: true,
80
+ stdio: 'ignore',
81
+ windowsHide: true,
82
+ }
83
+ );
84
+ child.on('error', (err) => {
85
+ process.stderr.write(`spotter-hook: refresh spawn error: ${err.message}\n`);
86
+ });
87
+ child.unref();
88
+ }
89
+
69
90
  function resolveSpotterBin() {
70
91
  const here = dirname(fileURLToPath(import.meta.url));
71
92
  return resolve(here, '..', '..', 'bin', 'spotter.mjs');
package/src/index.mjs CHANGED
@@ -17,5 +17,6 @@ export { loadDb, saveDb, emptyDb, ToolDbSchemaError, globalDbPath, localDbPath }
17
17
  export { resolveAll } from './tool-db/lookup.mjs';
18
18
  export { refresh, readMerged, buildInvestigationSnapshot } from './tool-db/refresh.mjs';
19
19
  export { listMcpServers, listMcpToolsAll, bellVisibleName, McpInvestigationError } from './tool-db/investigate-mcp.mjs';
20
- export { DEFERRED_TOOL_BASELINE, getDeferredDescription, listDeferredNames } from './tool-db/deferred-baseline.mjs';
20
+ export { listSkillsAll, listActivePlugins } from './tool-db/investigate-skills.mjs';
21
+ export { listAgentsAll } from './tool-db/investigate-agents.mjs';
21
22
  export { version } from './version.mjs';
@@ -0,0 +1,45 @@
1
+ // Minimal YAML frontmatter parser for Claude Code skill / agent `.md` files.
2
+ //
3
+ // We only need to read top-level scalar string fields (`name`, `description`). No nesting,
4
+ // no arrays, no multi-line strings. Anything more complex is ignored safely. Zero deps by
5
+ // design — the Spotter project goal forbids a YAML library for this small need.
6
+ //
7
+ // Supported input:
8
+ // ---
9
+ // name: council
10
+ // description: Convene a four-voice council ...
11
+ // origin: ECC
12
+ // ---
13
+ // <body...>
14
+ //
15
+ // Returns an object of frontmatter fields; unparseable / absent frontmatter → {}.
16
+
17
+ import { readFile } from 'node:fs/promises';
18
+
19
+ const FENCE = '---';
20
+
21
+ export async function readFrontmatter(path) {
22
+ const text = await readFile(path, 'utf8');
23
+ return parseFrontmatter(text);
24
+ }
25
+
26
+ export function parseFrontmatter(text) {
27
+ const lines = text.split(/\r?\n/);
28
+ if (lines[0]?.trim() !== FENCE) return {};
29
+ const out = {};
30
+ for (let i = 1; i < lines.length; i++) {
31
+ const line = lines[i];
32
+ if (line.trim() === FENCE) break;
33
+ const colonIdx = line.indexOf(':');
34
+ if (colonIdx <= 0) continue;
35
+ const key = line.slice(0, colonIdx).trim();
36
+ let value = line.slice(colonIdx + 1).trim();
37
+ if (value.length === 0) continue;
38
+ // Strip surrounding quotes if present.
39
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
40
+ value = value.slice(1, -1);
41
+ }
42
+ out[key] = value;
43
+ }
44
+ return out;
45
+ }
@@ -0,0 +1,75 @@
1
+ // Sub-agent investigation: collect agents from user scope, project scope, and enabled
2
+ // plugins.
3
+ //
4
+ // Agent file layout (per Claude Code convention):
5
+ // <root>/agents/<agent-name>.md (single .md with frontmatter)
6
+ // where <root> is:
7
+ // - user scope : ~/.claude
8
+ // - project scope: <projectRoot>/.claude
9
+ // - plugin scope : <installPath>
10
+ //
11
+ // Bell-visible name: just `<agent-name>` — sub-agents are NOT namespaced the way skills
12
+ // are. Name collisions (e.g. both ECC and a user dotfile defining `code-reviewer`) are
13
+ // resolved by precedence: project > user > plugin. Later writes to the Map override.
14
+
15
+ import { readdir } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { homedir } from 'node:os';
18
+ import { readFrontmatter } from './frontmatter.mjs';
19
+ import { listActivePlugins } from './investigate-skills.mjs';
20
+
21
+ export async function listAgentsAll({ logFn = () => {}, projectRoot } = {}) {
22
+ const out = new Map(); // Bell-visible name → description
23
+
24
+ // Plugin scope first (lowest precedence)
25
+ const plugins = await listActivePlugins({ projectRoot, logFn });
26
+ for (const plugin of plugins) {
27
+ const pluginAgents = await scanAgentsDir(join(plugin.installPath, 'agents'), logFn);
28
+ for (const [name, description] of pluginAgents) {
29
+ out.set(name, description);
30
+ }
31
+ }
32
+
33
+ // User scope (overrides plugin)
34
+ const userAgents = await scanAgentsDir(join(homedir(), '.claude', 'agents'), logFn);
35
+ for (const [name, description] of userAgents) {
36
+ out.set(name, description);
37
+ }
38
+
39
+ // Project scope (highest precedence)
40
+ if (projectRoot) {
41
+ const projectAgents = await scanAgentsDir(join(projectRoot, '.claude', 'agents'), logFn);
42
+ for (const [name, description] of projectAgents) {
43
+ out.set(name, description);
44
+ }
45
+ }
46
+
47
+ return out;
48
+ }
49
+
50
+ // Scan `<dir>/<name>.md` files. Returns Map<agent-name, description>. Missing directories
51
+ // or malformed agents are skipped silently.
52
+ async function scanAgentsDir(dir, logFn) {
53
+ const out = new Map();
54
+ let entries;
55
+ try {
56
+ entries = await readdir(dir, { withFileTypes: true });
57
+ } catch {
58
+ return out;
59
+ }
60
+ for (const entry of entries) {
61
+ if (!entry.isFile()) continue;
62
+ if (!entry.name.endsWith('.md')) continue;
63
+ const agentFile = join(dir, entry.name);
64
+ try {
65
+ const fm = await readFrontmatter(agentFile);
66
+ const name = fm.name ?? entry.name.replace(/\.md$/, '');
67
+ const description = fm.description;
68
+ if (typeof description !== 'string' || description.length === 0) continue;
69
+ out.set(name, description);
70
+ } catch (err) {
71
+ logFn(`agent read failed at ${agentFile}: ${err.message}`);
72
+ }
73
+ }
74
+ return out;
75
+ }
@@ -0,0 +1,129 @@
1
+ // Skill investigation: collect skills from user scope, project scope, and enabled plugins.
2
+ //
3
+ // Skill file layout (per Claude Code convention):
4
+ // <root>/skills/<skill-name>/SKILL.md
5
+ // where <root> is:
6
+ // - user scope : ~/.claude
7
+ // - project scope: <projectRoot>/.claude
8
+ // - plugin scope : <installPath> (from ~/.claude/plugins/installed_plugins.json)
9
+ //
10
+ // Bell-visible name:
11
+ // - plugin-provided : `<plugin-prefix>:<skill-name>` (e.g. `ecc:council`)
12
+ // - user / project : `<skill-name>` (e.g. `council`)
13
+ //
14
+ // Plugin prefix is the part before `@` in the plugin id (`ecc@ecc` → `ecc`).
15
+ //
16
+ // Plugins are considered active if either user-scope `~/.claude/settings.json` or
17
+ // project-scope `<projectRoot>/.claude/settings.local.json` sets
18
+ // `enabledPlugins[<plugin-id>] = true`, AND the plugin is listed in
19
+ // `~/.claude/plugins/installed_plugins.json`.
20
+
21
+ import { readdir, readFile, stat } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { readFrontmatter } from './frontmatter.mjs';
25
+
26
+ export async function listSkillsAll({ logFn = () => {}, projectRoot } = {}) {
27
+ const out = new Map(); // Bell-visible name → description
28
+
29
+ // Plugin scope
30
+ const plugins = await listActivePlugins({ projectRoot, logFn });
31
+ for (const plugin of plugins) {
32
+ const pluginSkills = await scanSkillsDir(join(plugin.installPath, 'skills'), logFn);
33
+ for (const [skillName, description] of pluginSkills) {
34
+ out.set(`${plugin.prefix}:${skillName}`, description);
35
+ }
36
+ }
37
+
38
+ // User scope (override plugin-same-name? No — plugin-namespaced names never collide
39
+ // with bare names, so no resolution needed.)
40
+ const userSkills = await scanSkillsDir(join(homedir(), '.claude', 'skills'), logFn);
41
+ for (const [skillName, description] of userSkills) {
42
+ out.set(skillName, description);
43
+ }
44
+
45
+ // Project scope (overrides user-scope with the same bare name)
46
+ if (projectRoot) {
47
+ const projectSkills = await scanSkillsDir(join(projectRoot, '.claude', 'skills'), logFn);
48
+ for (const [skillName, description] of projectSkills) {
49
+ out.set(skillName, description);
50
+ }
51
+ }
52
+
53
+ return out;
54
+ }
55
+
56
+ // Scan `<dir>/<name>/SKILL.md` files. Returns Map<skill-name, description>. Missing
57
+ // directories or malformed skills are skipped silently.
58
+ async function scanSkillsDir(dir, logFn) {
59
+ const out = new Map();
60
+ let entries;
61
+ try {
62
+ entries = await readdir(dir, { withFileTypes: true });
63
+ } catch {
64
+ return out;
65
+ }
66
+ for (const entry of entries) {
67
+ if (!entry.isDirectory()) continue;
68
+ const skillFile = join(dir, entry.name, 'SKILL.md');
69
+ try {
70
+ const fm = await readFrontmatter(skillFile);
71
+ const name = fm.name ?? entry.name;
72
+ const description = fm.description;
73
+ if (typeof description !== 'string' || description.length === 0) continue;
74
+ out.set(name, description);
75
+ } catch (err) {
76
+ // Directory without SKILL.md (e.g. `skills/learned/` placeholder) is normal —
77
+ // it simply isn't a skill. Only report unexpected errors.
78
+ if (err.code !== 'ENOENT') {
79
+ logFn(`skill read failed at ${skillFile}: ${err.message}`);
80
+ }
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // Return the list of plugins that are (a) installed and (b) enabled in user or project
87
+ // scope. Each entry: {id, prefix, installPath}.
88
+ export async function listActivePlugins({ projectRoot, logFn = () => {} } = {}) {
89
+ const [installed, userEnabled, projectEnabled] = await Promise.all([
90
+ readInstalledPlugins(logFn),
91
+ readEnabledPlugins(join(homedir(), '.claude', 'settings.json')),
92
+ projectRoot
93
+ ? readEnabledPlugins(join(projectRoot, '.claude', 'settings.local.json'))
94
+ : Promise.resolve({}),
95
+ ]);
96
+
97
+ const out = [];
98
+ for (const [id, entries] of Object.entries(installed)) {
99
+ const isEnabled = userEnabled[id] === true || projectEnabled[id] === true;
100
+ if (!isEnabled) continue;
101
+ const entry = entries?.[0];
102
+ if (!entry?.installPath) continue;
103
+ const prefix = id.split('@')[0];
104
+ out.push({ id, prefix, installPath: entry.installPath });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ async function readInstalledPlugins(logFn) {
110
+ const path = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
111
+ try {
112
+ const text = await readFile(path, 'utf8');
113
+ const parsed = JSON.parse(text);
114
+ return parsed.plugins ?? {};
115
+ } catch (err) {
116
+ logFn(`installed_plugins.json read failed: ${err.message}`);
117
+ return {};
118
+ }
119
+ }
120
+
121
+ async function readEnabledPlugins(settingsPath) {
122
+ try {
123
+ const text = await readFile(settingsPath, 'utf8');
124
+ const parsed = JSON.parse(text);
125
+ return parsed.enabledPlugins ?? {};
126
+ } catch {
127
+ return {};
128
+ }
129
+ }
@@ -1,6 +1,6 @@
1
1
  // tool-db: 3-tier lookup with write-through and drift correction.
2
2
  //
3
- // Per docs/catalog-design-deferred-mcp.md:
3
+ // Per docs/catalog-design.md:
4
4
  // 1. local DB hit → use it
5
5
  // 2. local miss, global hit → use it AND write-through to local
6
6
  // 3. both miss → investigate, write to BOTH
@@ -1,30 +1,32 @@
1
1
  // Discover available tools and ensure the tool-db has descriptions for all of them.
2
2
  //
3
- // Flow per docs/catalog-design-deferred-mcp.md:
4
- // 1. enumerate available tool names (MCP server tools + deferred Claude Code built-ins)
3
+ // Flow per docs/catalog-design.md:
4
+ // 1. enumerate available tool names (MCP + skills + sub-agents, user-addable only)
5
5
  // 2. for each, look up local → global → investigate (write-through; drift-correct)
6
6
  // 3. write back any updates atomically
7
+ //
8
+ // v1.0.0: Claude Code built-in tools (both immediate and deferred) are no longer in the
9
+ // catalog. The working assumption is Bell uses built-ins fluently without reminding.
10
+ // Spotter's audit surface is the tools the user actively adds: MCP servers, skills, and
11
+ // sub-agents.
7
12
 
8
13
  import { resolveAll } from './lookup.mjs';
9
14
  import { listMcpToolsAll, bellVisibleName } from './investigate-mcp.mjs';
10
- import { getDeferredDescription, listDeferredNames } from './deferred-baseline.mjs';
11
15
  import { getClaudeAiDescription, listClaudeAiNames } from './claude-ai-baseline.mjs';
16
+ import { listSkillsAll } from './investigate-skills.mjs';
17
+ import { listAgentsAll } from './investigate-agents.mjs';
12
18
  import { localDbPath, globalDbPath } from './loader.mjs';
13
19
 
14
- // Build the (name → description) map for an investigation pass:
15
- // - MCP: from `claude mcp list` + per-server `tools/list`, names rewritten to
16
- // `mcp__<server-id>__<tool-name>` (Bell-visible form)
17
- // - Deferred: from the hardcoded baseline
20
+ // Build the (name → description) map for an investigation pass across all sources:
21
+ // - claude.ai MCP baseline (Gmail / Calendar / Drive OAuth, not locally introspectable)
22
+ // - MCP servers via stdio + HTTP/SSE (user + project .mcp.json, live fetched)
23
+ // - Skills from user scope, project scope, and enabled plugins
24
+ // - Sub-agents from user scope, project scope, and enabled plugins
18
25
  //
19
26
  // Returns an in-memory snapshot the caller can use as the investigate() backend.
20
27
  export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
21
28
  const snapshot = new Map();
22
29
 
23
- // Deferred built-ins (Claude Code deferred tools like WebFetch, TodoWrite, etc.).
24
- for (const name of listDeferredNames()) {
25
- snapshot.set(name, getDeferredDescription(name));
26
- }
27
-
28
30
  // Anthropic-provided `claude.ai ...` MCP servers — hardcoded because the OAuth proxy
29
31
  // is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
30
32
  // If a live HTTP investigate for the same name later succeeds below, it overrides.
@@ -42,6 +44,18 @@ export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin =
42
44
  }
43
45
  }
44
46
 
47
+ // Skills (plugin-namespaced as `<plugin>:<name>`, or bare for user/project scope).
48
+ const skills = await listSkillsAll({ logFn, projectRoot });
49
+ for (const [name, description] of skills) {
50
+ snapshot.set(name, description);
51
+ }
52
+
53
+ // Sub-agents (bare name; project > user > plugin precedence resolved internally).
54
+ const agents = await listAgentsAll({ logFn, projectRoot });
55
+ for (const [name, description] of agents) {
56
+ snapshot.set(name, description);
57
+ }
58
+
45
59
  return snapshot;
46
60
  }
47
61
 
@@ -1,49 +0,0 @@
1
- // Hand-curated descriptions for Claude Code built-in deferred tools.
2
- //
3
- // Why hardcoded: Claude Code's built-in tools cannot be queried introspectively from a
4
- // daemon process (they're internal to the Claude binary, not exposed via MCP). The list
5
- // is small and stable; we maintain it here and update on Claude Code releases.
6
- //
7
- // `spotter db refresh` merges this baseline into the global DB. Users can override
8
- // per-project by editing their local DB (per docs/catalog-design-deferred-mcp.md).
9
-
10
- export const DEFERRED_TOOL_BASELINE = {
11
- // Reasoning / dialogue
12
- 'AskUserQuestion': 'Ask the user a clarifying question with multi-choice or free-text answer when requirements are ambiguous; preferable to guessing.',
13
- 'TodoWrite': 'Create or update a structured task list to plan and track multi-step work; surfaces progress to the user and to future turns.',
14
- 'EnterPlanMode': 'Enter Plan Mode: Claude proposes a plan first and waits for user approval before doing the work.',
15
- 'ExitPlanMode': 'Exit Plan Mode after the user has approved the proposed plan.',
16
-
17
- // Web access
18
- 'WebSearch': 'Search the web for up-to-date information (news, prices, releases, events that postdate training).',
19
- 'WebFetch': 'Fetch the contents of a specific URL to read its body (HTML/Markdown/JSON), e.g. when the user pastes a link.',
20
-
21
- // Notebook
22
- 'NotebookEdit': 'Edit cells in a Jupyter notebook (.ipynb) — insert, replace, or delete cells.',
23
-
24
- // Worktree / isolated execution
25
- 'EnterWorktree': 'Create a temporary git worktree to isolate experimental edits from the main checkout.',
26
- 'ExitWorktree': 'Tear down a previously-created worktree.',
27
-
28
- // Background processes / monitoring
29
- 'Monitor': 'Stream events from a long-running background process; receives a notification per stdout line.',
30
- 'PushNotification': 'Send a push notification to the user (e.g. on long task completion).',
31
-
32
- // Scheduling / cron
33
- 'CronCreate': 'Schedule a recurring agent (cron-style trigger) that runs Claude Code on an interval.',
34
- 'CronDelete': 'Delete a scheduled cron trigger by id.',
35
- 'CronList': 'List all currently configured cron triggers.',
36
- 'RemoteTrigger': 'Create or run a remote trigger (one-shot or recurring scheduled agent).',
37
-
38
- // Sub-agent / task control
39
- 'TaskOutput': 'Read the latest output from a running background sub-agent.',
40
- 'TaskStop': 'Stop a running background sub-agent.',
41
- };
42
-
43
- export function getDeferredDescription(name) {
44
- return DEFERRED_TOOL_BASELINE[name] ?? null;
45
- }
46
-
47
- export function listDeferredNames() {
48
- return Object.keys(DEFERRED_TOOL_BASELINE);
49
- }