claude-spotter 0.6.1 → 0.7.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,80 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ **カタログを tool-db に置き換え**。手書きの `tools.yaml` (5 つの抽象ツール) を捨て、**実際にセッションで使えるツール (MCP + Claude Code 組込み 遅延ツール) の name + description を自動収集してキャッシュする** 仕組みに置き換え。
6
+
7
+ ### 事の発端
8
+
9
+ Haiku が「Bell が呼び忘れているツール」を判定するには、Bell が今のセッションで実際に呼べるツールを知っている必要がある。v0.6.x までのカタログは `current_time` / `web_search` / `read_file` のような **抽象的な汎用ツール 5 件** を手書きしていただけで、Caveat や Gmail のような MCP ツール、TodoWrite や WebSearch のような Claude Code 組込みの遅延ツールは Haiku の視野に入っていなかった。結果、ユーザーが「過去に解決したナレッジを残したい」と言っても Spotter は Caveat を推奨できないという的外れな状態だった。
10
+
11
+ 設計思想は [docs/catalog-design-deferred-mcp.md](docs/catalog-design-deferred-mcp.md) に集約。要点:
12
+
13
+ - **Haiku に渡すのは name + description のペアだけ**。schema は不要 — どう呼ぶかは Bell が ToolSearch で解決する責任 (役割分業)
14
+ - **MCP ツールの description は MCP サーバーから直接取得**。Spotter は中継者に徹し、手書きで言い換えない (single source of truth = MCP server)
15
+ - **3 段階キャッシュ DB**: ローカル (プロジェクト) → グローバル (`~`) → 「調べる」(調査結果は両方に書き込む)
16
+ - **drift 補正**: ローカル ≠ グローバルなら再調査して MCP server の現在値で両方上書き
17
+ - **明示的な無効化機構なし**: drift 補正が間接無効化として機能、TTL なし
18
+
19
+ ### 変更点
20
+
21
+ - **新規 [src/tool-db/loader.mjs](src/tool-db/loader.mjs)**: JSON DB の atomic 読み書き、`{version, tools: {name → description}}` スキーマ検証
22
+ - **新規 [src/tool-db/lookup.mjs](src/tool-db/lookup.mjs)**: 3 段階 lookup + write-through + drift 補正
23
+ - **新規 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)**: `claude mcp list` / `claude mcp get` で MCP サーバー列挙、stdio サーバーに JSON-RPC で `initialize` + `tools/list` を実行して description 取得
24
+ - **新規 [src/tool-db/deferred-baseline.mjs](src/tool-db/deferred-baseline.mjs)**: Claude Code 組込み 遅延ツール (WebSearch / TodoWrite / 等 17 件) の手書き description ベースライン (Claude Code 自体は MCP 経由で query できないため)
25
+ - **新規 [src/tool-db/refresh.mjs](src/tool-db/refresh.mjs)**: 投資 = 利用可能ツール一覧取得 + 各ツールを 3 段階解決 + DB 書き戻し
26
+ - **新規 [src/cli/db-cmd.mjs](src/cli/db-cmd.mjs)**: `spotter db list` / `refresh` / `rebuild`
27
+ - **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: `loadCatalog` 廃止、`startDaemon({ projectRoot })` で tool-db を読み込み (テスト用に `tools` 直接指定も可)
28
+ - **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `buildPreamble({ catalog })` → `buildPreamble({ tools })`、tools は `[{name, description}]`
29
+ - **編集 [src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**, **[src/hooks/session-start.mjs](src/hooks/session-start.mjs)**: `--project-root` を hook → daemon に伝達
30
+ - **編集 [src/cli/install.mjs](src/cli/install.mjs)**: `tool-catalog/` 作成と template コピー削除、install 完了時に `spotter db refresh` 実行を案内
31
+ - **編集 [src/cli/doctor.mjs](src/cli/doctor.mjs)**: catalog チェック → tool-db (global + local) のチェック
32
+ - **編集 [bin/spotter.mjs](bin/spotter.mjs)**: `spotter catalog edit/lint` を `spotter db list/refresh/rebuild` に置換
33
+ - **削除**: [src/catalog/](src/catalog/), [src/cli/catalog.mjs](src/cli/catalog.mjs), `templates/tools.yaml`, `test/catalog.test.mjs`, `test/loader.test.mjs`
34
+ - **新規 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: 21 件 (loader/lookup/investigate-mcp/deferred-baseline)
35
+
36
+ ### Breaking
37
+
38
+ - `~/.spotter/tool-catalog/tools.yaml` は読まれなくなる。install 後 `spotter db refresh` を実行して `~/.spotter/tool-db.json` (グローバル) と `<project>/.spotter/tool-db.json` (ローカル) を populate する必要がある
39
+ - `spotter catalog edit/lint` コマンド廃止 → `spotter db list/refresh/rebuild`
40
+ - `startDaemon` シグネチャ変更: `catalogPath` 廃止、`tools` または `projectRoot` のいずれか必須
41
+ - `buildPreamble({ catalog })` → `buildPreamble({ tools })`
42
+ - `src/index.mjs` から `loadCatalog`, `validateCatalog`, `runLint` 等を削除、tool-db API を export
43
+ - `claude mcp list` のエラー / SSE/HTTP transport 未対応のため、これらサーバーの description は取れない (今後の課題)
44
+
45
+ ### 既知の制約
46
+
47
+ - HTTP/SSE transport の MCP サーバーは investigate でスキップ (将来 HTTP MCP クライアント実装で対応)
48
+ - Claude Code の遅延ツール一覧は hardcoded baseline のみ。Claude Code が新しい built-in を追加したら baseline 更新が必要
49
+ - `claude mcp list` の出力フォーマット変更には脆い (parse 依存)。JSON 出力モードが将来追加されたらそちらに切り替えたい
50
+
51
+ ## 0.6.2
52
+
53
+ **親プロセス watch による孤児 daemon 自動回収**。SessionEnd が発火しない経路 (Claude Code crash, kill -9, IDE reload) で daemon が永久に残る問題への対処。
54
+
55
+ ### 事の発端
56
+
57
+ 実運用で `spotter status` を見ると、現セッション以外に複数の daemon が `process=alive` で残存している状態が頻発していた。今回の観測では 9 個中 8 個が孤児で、手動 `taskkill` + `.pid` ファイル削除で掃除する必要があった。原因は SessionEnd hook が**正常終了経路でしか発火しない**こと。Claude Code の crash、強制終了、VSCode リロード等のいずれかで daemon は親を失っても生き続ける。v0.2 スコープに「孤児 cleanup」と書いてあったが未実装のままだった。
58
+
59
+ ### 変更点
60
+
61
+ - **[src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: `startDaemon({ parentPid, parentWatchIntervalMs })` を追加。`parentPid` が指定されると 5 秒間隔 (default) で `process.kill(parentPid, 0)` を ping し、ESRCH を検知したら自身を shutdown。`parentWatchIntervalMs` はテスト用に短縮可能。`parentPid !== null && (!Number.isInteger || <= 0)` は TypeError で reject。
62
+ - **[src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: `--parent-pid <N>` 引数をパースして `startDaemon` に渡す。
63
+ - **[src/hooks/session-start.mjs](src/hooks/session-start.mjs)**: daemon spawn 時に `--parent-pid <process.ppid>` を付与。`process.ppid` は SessionStart hook から見た親 = Claude Code 本体。
64
+ - **[test/daemon.test.mjs](test/daemon.test.mjs)**: 子プロセスを fake parent として spawn → daemon 起動 → 子を SIGKILL → daemon の `server.on('close')` が発火することを検証する E2E テストを追加。`parentPid: 0` / `1.5` が TypeError になることのバリデーションテストも追加。
65
+
66
+ ### 効果
67
+
68
+ - 通常運用 (SessionEnd 発火経路) では従来どおり graceful shutdown
69
+ - 異常終了経路 (crash, kill, reload) では親消滅を最大 5 秒で検知して自殺
70
+ - 観測コスト: `process.kill(pid, 0)` の syscall が 5 秒に 1 回。idle CPU 影響は無視できる程度
71
+
72
+ ### 既知の制約
73
+
74
+ - 親 PID を持たない経路 (手動 `spotter daemon start --session-id ...`) では watch が動かない (parentPid が null)。これは debug 用なので許容。
75
+ - Claude Code が中間プロセス (cmd.exe / sh wrapper) 越しに hook を起動している場合、`process.ppid` が wrapper を指す可能性あり。今回の Windows 環境では実測で Claude Code 本体を指していたが、将来的に環境差で問題が出れば PID 取得方法を再検討。
76
+ - 5 秒間隔のため、kill 直後の最大 5 秒は孤児状態が残る。これ以上短縮するなら polling コストとのトレードオフ再評価。
77
+
3
78
  ## 0.6.1
4
79
 
5
80
  **v0.6.0 で `src/version.mjs` を更新し忘れた trivia fix**。`spotter --version` が古い `0.5.2` を返していた。挙動差はない。
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Spotter
2
2
 
3
- > **v0.4.3 released 2026-04-19**. v0.2.0 で追加した session-scoped Haiku Spotter 本体プロジェクトでの長時間運用中に role collapse (Haiku Bell 人格に drift) を起こしたため、v0.4.0 stateless に回帰。v0.4.2 stateless 化の副作用として発生した cold-start timeout 問題に対処 (timeout 28s60s + stateless-safe warmup)、v0.4.3 で過剰になっていたプロンプトを 30-40% 削減。詳細は [CHANGELOG](CHANGELOG.md)。
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)。
4
4
 
5
5
  **気づく役と実行する役を分離する。** Spotter は Claude Code の横で静かに並走し、Bell (主役の Claude) が**ツールを呼び忘れたとき**に指摘する監査役です。
6
6
 
7
7
  > Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。現在時刻を推測で答える、web_search を呼ばずに古い情報で応答する、read_file を使わずにファイルの中身を推測する — 「分からないと自覚できない」から、ツールを取りに行けない。
8
8
 
9
- Spotter は、ツールカタログを完全に把握した別エージェント (Claude Haiku 4.5) をセッション毎にプロセスとして常駐させ、Bell の発話予定と応答を並走監査します。見落としを検出すると、透明化された指摘として Bell に届け、補正応答を促します。Haiku 呼び出しは毎ターン stateless (fresh `--session-id`) で実行されるため、長時間運用しても会話履歴による persona drift を構造的に防ぎます。
9
+ Spotter は、ツールカタログを完全に把握した別エージェント (Claude Haiku 4.5) をセッション毎にプロセスとして常駐させ、Bell の発話予定と応答を並走監査します。見落としを検出すると、透明化された指摘として Bell に届け、補正応答を促します。Haiku 呼び出しは session-scoped (`--resume`) で同一セッションに再接続して cold-start を削減し、初回のみ preamble を送って以降は per-turn delta だけ送ることで session 肥大化を防ぎます。role collapse (persona drift で JSON 契約破棄) は構造的に予防せず、検知した瞬間に session を切り直して fresh state から再開する事後回復機構で長時間運用に耐えます。
10
10
 
11
11
  ## インストール
12
12
 
@@ -44,7 +44,7 @@ Stop hook → Spotter が応答と使用済みツールを見て最終チェッ
44
44
  見落としあれば差し戻し (max 1 回、Claude Code の stop_hook_active で自動担保)
45
45
  ```
46
46
 
47
- `~/.spotter/tool-catalog/tools.yaml` に監査対象のツール用途を記述します。`current_time` / `web_search` / `read_file` / `list_directory` / `run_command` の雛形付き。
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 からカバーします。**手書きでツールリストを管理する必要はありません**。
48
48
 
49
49
  ## Throughline との関係
50
50
 
@@ -61,10 +61,11 @@ Stop hook → Spotter が応答と使用済みツールを見て最終チェッ
61
61
  ## よく使うコマンド
62
62
 
63
63
  ```bash
64
- spotter catalog edit # ツールカタログを $EDITOR で開く
65
- spotter catalog lint # YAML 検証 + test_cases Haiku 実呼びで検証
64
+ spotter db list # 現在の tool-db (local + global merged) を表示
65
+ spotter db refresh # MCP サーバーと組込み 遅延ツールから description を収集して DB 更新
66
+ spotter db rebuild # ローカル DB を消してから refresh (強制再投資)
66
67
  spotter status # 稼働中の daemon 一覧
67
- spotter doctor # 環境診断 (Node / claude CLI / カタログ整合性)
68
+ spotter doctor # 環境診断 (Node / claude CLI / tool-db 整合性)
68
69
  spotter uninstall # hook 登録を解除 (~/.spotter は残す)
69
70
  ```
70
71
 
@@ -75,7 +76,7 @@ spotter uninstall # hook 登録を解除 (~/.spotter は残す)
75
76
  ## 既知の制約
76
77
 
77
78
  - Stop hook は Bell の最初の応答が**出力された後**に発火するため、Spotter が Stop で差し戻した場合、ユーザーは「最初の応答 + 補正応答」の 2 連続を見ます (Claude Code の hook 仕様による制約)。UserPromptSubmit 段階での先回り検出を精度の軸にしています
78
- - JSON スキーマ違反・Haiku timeout はリトライせず即 throw します (§14.1 silent fallback 禁止の帰結)UserPromptSubmit がブロックされユーザー入力が Bell に届かない症状として顕在化します。cold-start 対策として v0.4.2 で timeout 60s + warmup を導入しましたが、fail-open 化 (timeout を pass 扱い) は §0 改訂とセットで今後検討
79
+ - **JSON スキーマ違反は v0.5.0 以降「想定済み異常」として silent pass + session renew で回復**します (role collapse 検知パス、daemon ログに `role_collapse_reset` を残す)。一方 **Haiku timeout は引き続き throw** され、UserPromptSubmit がブロックされてユーザー入力が Bell に届かない症状として顕在化します (timeout v0.5.030s に短縮)。timeout fail-open 化 (pass 扱い) は §0 改訂とセットで今後検討
79
80
 
80
81
  ## ライセンス
81
82
 
package/bin/spotter.mjs CHANGED
@@ -6,7 +6,7 @@ import { runInstall } from '../src/cli/install.mjs';
6
6
  import { runUninstall } from '../src/cli/uninstall.mjs';
7
7
  import { runDoctor } from '../src/cli/doctor.mjs';
8
8
  import { runStatus } from '../src/cli/status.mjs';
9
- import { runCatalogEdit, runCatalogLint } from '../src/cli/catalog.mjs';
9
+ import { runDbList, runDbRefresh, runDbRebuild } from '../src/cli/db-cmd.mjs';
10
10
  import { runDaemonStart } from '../src/cli/daemon-cmd.mjs';
11
11
  import { runSessionStart } from '../src/hooks/session-start.mjs';
12
12
  import { runUserPrompt } from '../src/hooks/user-prompt.mjs';
@@ -26,8 +26,9 @@ Usage:
26
26
  spotter uninstall [-y] remove spotter hooks from <cwd>/.claude/settings.json
27
27
  and remove <cwd>/.spotter/marker.json
28
28
  spotter uninstall --user [-y] remove from ~/.claude/settings.json
29
- spotter catalog edit open tool catalog in $EDITOR
30
- spotter catalog lint validate catalog + run test_cases (Haiku live call)
29
+ spotter db list show merged tool-db (local + global)
30
+ spotter db refresh discover MCP / deferred tools and update DB
31
+ spotter db rebuild wipe local DB then refresh
31
32
  spotter status show running daemons
32
33
  spotter doctor environment diagnostic
33
34
  spotter daemon start --session-id ID (internal) run session daemon
@@ -64,11 +65,12 @@ async function main() {
64
65
  await runUninstall({ target, autoYes });
65
66
  return;
66
67
  }
67
- case 'catalog': {
68
+ case 'db': {
68
69
  const sub = rest[0];
69
- if (sub === 'edit') { await runCatalogEdit(); return; }
70
- if (sub === 'lint') { await runCatalogLint(); return; }
71
- process.stderr.write(`unknown catalog subcommand: ${sub}\n${USAGE}`);
70
+ if (sub === 'list') { await runDbList(); return; }
71
+ if (sub === 'refresh') { await runDbRefresh(); return; }
72
+ if (sub === 'rebuild') { await runDbRebuild(); return; }
73
+ process.stderr.write(`unknown db subcommand: ${sub}\n${USAGE}`);
72
74
  process.exit(2);
73
75
  return;
74
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,6 @@
43
43
  "bin",
44
44
  "src",
45
45
  "scripts",
46
- "templates",
47
46
  "README.md",
48
47
  "LICENSE",
49
48
  "CHANGELOG.md"
@@ -6,22 +6,33 @@ import { join } from 'node:path';
6
6
  import { open } from 'node:fs/promises';
7
7
 
8
8
  function parseArgs(argv) {
9
- const out = { sessionId: null };
9
+ const out = { sessionId: null, parentPid: null, projectRoot: null };
10
10
  for (let i = 0; i < argv.length; i += 1) {
11
11
  if (argv[i] === '--session-id') {
12
12
  out.sessionId = argv[i + 1];
13
13
  i += 1;
14
+ } else if (argv[i] === '--parent-pid') {
15
+ const n = parseInt(argv[i + 1], 10);
16
+ if (Number.isInteger(n) && n > 0) out.parentPid = n;
17
+ i += 1;
18
+ } else if (argv[i] === '--project-root') {
19
+ out.projectRoot = argv[i + 1];
20
+ i += 1;
14
21
  }
15
22
  }
16
23
  return out;
17
24
  }
18
25
 
19
26
  export async function runDaemonStart({ argv }) {
20
- const { sessionId } = parseArgs(argv);
27
+ const { sessionId, parentPid, projectRoot } = parseArgs(argv);
21
28
  if (!sessionId) {
22
29
  process.stderr.write('spotter daemon start: --session-id is required\n');
23
30
  process.exit(2);
24
31
  }
32
+ if (!projectRoot) {
33
+ process.stderr.write('spotter daemon start: --project-root is required (the path containing .spotter/marker.json)\n');
34
+ process.exit(2);
35
+ }
25
36
 
26
37
  const logFile = await open(
27
38
  join(homedir(), '.spotter', 'logs', `daemon-${sessionId}.log`),
@@ -37,7 +48,11 @@ export async function runDaemonStart({ argv }) {
37
48
  // v0.5.0: no warmup. Session-scoped Haiku (--resume on follow-ups) pays cold-start
38
49
  // only on the first real call; warmup added complexity for marginal benefit and is
39
50
  // removed along with the stateless regime that required it.
40
- running = await startDaemon({ sessionId, logFn: log });
51
+ // v0.6.2: parentPid (Claude Code PID, captured by SessionStart hook as process.ppid)
52
+ // is threaded in so the daemon self-terminates when the parent dies without
53
+ // SessionEnd (crash / kill / IDE reload).
54
+ // v0.7.0: projectRoot drives tool-db loading (replaces the old tools.yaml catalog).
55
+ running = await startDaemon({ sessionId, projectRoot, parentPid, logFn: log });
41
56
  } catch (err) {
42
57
  if (err instanceof DaemonAlreadyRunningError) {
43
58
  // v0.2 PID-preexist layer: a sibling daemon already serves this session.
@@ -0,0 +1,55 @@
1
+ // `spotter db` — manage the tool-db.
2
+ //
3
+ // spotter db list — print merged tool-db (local overrides global)
4
+ // spotter db refresh — discover available tools and update DB (3-tier resolve)
5
+ // spotter db rebuild — wipe local DB then refresh (forces re-investigation)
6
+ //
7
+ // Run inside a project that has been `spotter install`-ed.
8
+
9
+ import { findSpotterMarker } from '../hooks/lib.mjs';
10
+ import { refresh, readMerged } from '../tool-db/refresh.mjs';
11
+ import { localDbPath, globalDbPath, loadDb, saveDb, emptyDb } from '../tool-db/loader.mjs';
12
+ import { writeFile } from 'node:fs/promises';
13
+
14
+ function requireProjectRoot() {
15
+ const root = findSpotterMarker(process.cwd());
16
+ if (!root) {
17
+ process.stderr.write('spotter db: no .spotter/marker.json found in or above cwd. Run `spotter install` first.\n');
18
+ process.exit(2);
19
+ }
20
+ return root;
21
+ }
22
+
23
+ export async function runDbList() {
24
+ const projectRoot = requireProjectRoot();
25
+ const tools = await readMerged({ projectRoot });
26
+ if (tools.length === 0) {
27
+ process.stdout.write('(empty — run `spotter db refresh` to populate)\n');
28
+ return;
29
+ }
30
+ for (const { name, description } of tools) {
31
+ process.stdout.write(`${name}\n ${description}\n\n`);
32
+ }
33
+ }
34
+
35
+ export async function runDbRefresh() {
36
+ const projectRoot = requireProjectRoot();
37
+ const log = (msg) => process.stderr.write(`spotter db refresh: ${msg}\n`);
38
+ log('discovering MCP servers and built-in deferred tools...');
39
+ const resolved = await refresh({ projectRoot, logFn: log });
40
+ const counts = { local: 0, global: 0, investigated: 0 };
41
+ for (const { source } of resolved.values()) counts[source] = (counts[source] ?? 0) + 1;
42
+ process.stdout.write(
43
+ `${resolved.size} tool(s) resolved (local=${counts.local}, global=${counts.global}, investigated=${counts.investigated})\n`
44
+ + `local DB: ${localDbPath(projectRoot)}\n`
45
+ + `global DB: ${globalDbPath()}\n`
46
+ );
47
+ }
48
+
49
+ export async function runDbRebuild() {
50
+ const projectRoot = requireProjectRoot();
51
+ // Wipe local DB so every tool's resolution falls through to global → investigate.
52
+ await saveDb(localDbPath(projectRoot), emptyDb());
53
+ process.stderr.write(`spotter db rebuild: cleared local DB, refreshing...\n`);
54
+ await runDbRefresh();
55
+ }
@@ -5,7 +5,8 @@ import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
- import { loadCatalog } from '../catalog/loader.mjs';
8
+ import { loadDb, globalDbPath, localDbPath } from '../tool-db/loader.mjs';
9
+ import { findSpotterMarker } from '../hooks/lib.mjs';
9
10
 
10
11
  const execFileP = promisify(execFile);
11
12
 
@@ -37,23 +38,41 @@ export async function runDoctor() {
37
38
 
38
39
  // ~/.spotter directories
39
40
  const home = join(homedir(), '.spotter');
40
- for (const sub of ['tool-catalog', 'runtime', 'workdir', 'logs']) {
41
+ for (const sub of ['runtime', 'workdir', 'logs']) {
41
42
  const path = join(home, sub);
42
43
  const ok = await exists(path);
43
44
  mark(ok, `dir ${path}`);
44
45
  if (!ok) warnings += 1;
45
46
  }
46
47
 
47
- // catalog
48
- const catalogPath = join(home, 'tool-catalog', 'tools.yaml');
48
+ // tool-db (global)
49
49
  try {
50
- const cat = await loadCatalog(catalogPath);
51
- mark(true, `catalog: ${cat.tools.length} tools at ${catalogPath}`);
50
+ const global = await loadDb(globalDbPath());
51
+ const count = Object.keys(global.tools).length;
52
+ if (count === 0) {
53
+ mark(false, `global tool-db: empty (run \`spotter db refresh\`)`);
54
+ warnings += 1;
55
+ } else {
56
+ mark(true, `global tool-db: ${count} tools at ${globalDbPath()}`);
57
+ }
52
58
  } catch (err) {
53
- mark(false, 'catalog', err.message);
59
+ mark(false, 'global tool-db', err.message);
54
60
  failures += 1;
55
61
  }
56
62
 
63
+ // tool-db (local) if cwd is inside a Spotter project
64
+ const projectRoot = findSpotterMarker(process.cwd());
65
+ if (projectRoot) {
66
+ try {
67
+ const local = await loadDb(localDbPath(projectRoot));
68
+ const count = Object.keys(local.tools).length;
69
+ mark(true, `local tool-db: ${count} tools at ${localDbPath(projectRoot)}`);
70
+ } catch (err) {
71
+ mark(false, 'local tool-db', err.message);
72
+ failures += 1;
73
+ }
74
+ }
75
+
57
76
  console.log('');
58
77
  if (failures > 0) {
59
78
  console.log(`result: ${failures} failure(s), ${warnings} warning(s)`);
@@ -1,12 +1,16 @@
1
- // `spotter install` — create ~/.spotter/, place template catalog, register hooks in .claude/settings.json.
1
+ // `spotter install` — create ~/.spotter/, register hooks in .claude/settings.json.
2
2
  //
3
3
  // Per plan §15.4, this shows a diff and asks for confirmation before touching settings.json.
4
4
  //
5
5
  // v0.3: also writes <cwd>/.spotter/marker.json (project mode) so hooks can detect
6
6
  // "this Claude Code session is rooted in a project where Spotter is installed" and
7
7
  // silently exit otherwise (prevents Throughline-style proliferation).
8
+ //
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.
8
12
 
9
- import { mkdir, writeFile, readFile, access, copyFile } from 'node:fs/promises';
13
+ import { mkdir, writeFile, readFile, access } from 'node:fs/promises';
10
14
  import { homedir } from 'node:os';
11
15
  import { join, resolve, dirname } from 'node:path';
12
16
  import { fileURLToPath } from 'node:url';
@@ -15,11 +19,9 @@ import { version as SPOTTER_VERSION } from '../version.mjs';
15
19
 
16
20
  const HERE = dirname(fileURLToPath(import.meta.url));
17
21
  const PACKAGE_ROOT = resolve(HERE, '..', '..');
18
- const TEMPLATE_CATALOG = join(PACKAGE_ROOT, 'templates', 'tools.yaml');
19
22
  const SPOTTER_BIN = join(PACKAGE_ROOT, 'bin', 'spotter.mjs');
20
23
 
21
24
  const SPOTTER_HOME = join(homedir(), '.spotter');
22
- const CATALOG_DEST = join(SPOTTER_HOME, 'tool-catalog', 'tools.yaml');
23
25
 
24
26
  const MARKER_VERSION = '1';
25
27
 
@@ -42,20 +44,11 @@ export async function runInstall({ target = 'project', autoYes = false, cwd = pr
42
44
 
43
45
  // 1. create directories
44
46
  await mkdir(SPOTTER_HOME, { recursive: true });
45
- await mkdir(join(SPOTTER_HOME, 'tool-catalog'), { recursive: true });
46
47
  await mkdir(join(SPOTTER_HOME, 'runtime'), { recursive: true });
47
48
  await mkdir(join(SPOTTER_HOME, 'workdir'), { recursive: true });
48
49
  await mkdir(join(SPOTTER_HOME, 'logs'), { recursive: true });
49
50
 
50
- // 2. place catalog template if missing
51
- if (!(await exists(CATALOG_DEST))) {
52
- await copyFile(TEMPLATE_CATALOG, CATALOG_DEST);
53
- console.log(` wrote ${CATALOG_DEST}`);
54
- } else {
55
- console.log(` catalog already present at ${CATALOG_DEST} (not overwritten)`);
56
- }
57
-
58
- // 3. project marker (v0.3): hooks use this to detect installed projects.
51
+ // 2. project marker (v0.3): hooks use this to detect installed projects.
59
52
  // Skipped in user-mode install — user-mode is a deprecated escape hatch and
60
53
  // intentionally has no marker, so all hooks would exit. (Existing user-mode
61
54
  // installs from <0.3 won't surprise-stop working only because of this — they
@@ -103,7 +96,9 @@ export async function runInstall({ target = 'project', autoYes = false, cwd = pr
103
96
  await mkdir(dirname(settingsPath), { recursive: true });
104
97
  await writeFile(settingsPath, JSON.stringify(updated, null, 2) + '\n', 'utf8');
105
98
  console.log(`wrote ${settingsPath}`);
106
- console.log('\nnext: reload Claude Code (or open a new session) to activate Spotter.');
99
+ 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');
107
102
  }
108
103
 
109
104
  async function exists(path) {
@@ -79,7 +79,7 @@ export async function runUninstall({ target = 'project', autoYes = false, cwd =
79
79
 
80
80
  await writeFile(settingsPath, JSON.stringify(updated, null, 2) + '\n', 'utf8');
81
81
  console.log(`wrote ${settingsPath}`);
82
- console.log('note: ~/.spotter/ (catalog, logs) was not removed. delete manually if no longer needed.');
82
+ console.log('note: ~/.spotter/ (tool-db, logs) was not removed. delete manually if no longer needed.');
83
83
  }
84
84
 
85
85
  async function removeMarker(cwd) {
@@ -35,18 +35,21 @@ import {
35
35
  createHaikuCaller,
36
36
  HaikuError,
37
37
  } from './haiku-caller.mjs';
38
- import { loadCatalog } from '../catalog/loader.mjs';
38
+ import { readMerged } from '../tool-db/refresh.mjs';
39
39
  import { homedir } from 'node:os';
40
40
  import { join } from 'node:path';
41
41
  import { writeFile, unlink } from 'node:fs/promises';
42
-
43
- const DEFAULT_CATALOG_PATH = join(homedir(), '.spotter', 'tool-catalog', 'tools.yaml');
44
42
  const DEFAULT_HAIKU_CALL_WINDOW_MS = 10_000;
45
43
  // v0.5.0: lowered 60s → 30s. Session-scoped (--resume) means the first call still pays
46
44
  // cold-start but subsequent calls skip it. 30s covers the first-call cold path without
47
45
  // being excessive, and a role-collapse recovery cycle (reset → next call is effectively
48
46
  // a cold start again) stays within budget.
49
47
  const DEFAULT_HAIKU_TIMEOUT_MS = 30_000;
48
+ // v0.6.2: parent-process watch interval. SessionStart hook passes the Claude Code PID;
49
+ // the daemon polls it so it can self-terminate when Claude Code dies without firing
50
+ // SessionEnd (crash, kill -9, IDE reload). 5s is a balance between responsiveness and
51
+ // idle CPU cost — well below the cost of an orphan daemon staying up indefinitely.
52
+ const DEFAULT_PARENT_WATCH_INTERVAL_MS = 5_000;
50
53
 
51
54
  export class DaemonAlreadyRunningError extends Error {
52
55
  constructor(sessionId, pid) {
@@ -59,14 +62,20 @@ export class DaemonAlreadyRunningError extends Error {
59
62
 
60
63
  export async function startDaemon({
61
64
  sessionId,
62
- catalogPath = DEFAULT_CATALOG_PATH,
65
+ projectRoot,
66
+ tools,
63
67
  haikuCaller,
64
68
  logFn = () => {},
65
69
  haikuCallWindowMs = DEFAULT_HAIKU_CALL_WINDOW_MS,
70
+ parentPid = null,
71
+ parentWatchIntervalMs = DEFAULT_PARENT_WATCH_INTERVAL_MS,
66
72
  } = {}) {
67
73
  if (!sessionId) {
68
74
  throw new TypeError('sessionId is required');
69
75
  }
76
+ if (parentPid !== null && (!Number.isInteger(parentPid) || parentPid <= 0)) {
77
+ throw new TypeError('parentPid must be a positive integer or null');
78
+ }
70
79
 
71
80
  await ensureRuntimeDir();
72
81
 
@@ -74,14 +83,24 @@ export async function startDaemon({
74
83
  // a sibling daemon is already serving this session_id — throw so the caller can exit.
75
84
  await assertNoLiveDaemon(sessionId);
76
85
 
77
- // Load catalog up front daemon cannot run without it (§14.1).
78
- const catalog = await loadCatalog(catalogPath);
79
- logFn(`catalog loaded: ${catalog.tools.length} tools from ${catalogPath}`);
86
+ // v0.7.0: tool list comes from tool-db (local + global merged with local-wins).
87
+ // For tests, the caller can pass `tools` directly. For production, projectRoot drives
88
+ // the load from <projectRoot>/.spotter/tool-db.json + ~/.spotter/tool-db.json.
89
+ let toolList;
90
+ if (Array.isArray(tools)) {
91
+ toolList = tools;
92
+ } else {
93
+ if (!projectRoot) {
94
+ throw new TypeError('startDaemon: either `tools` or `projectRoot` must be provided');
95
+ }
96
+ toolList = await readMerged({ projectRoot });
97
+ }
98
+ logFn(`tool-db loaded: ${toolList.length} tools` + (projectRoot ? ` (project=${projectRoot})` : ''));
80
99
 
81
100
  // v0.6.0: preamble (role + schema + catalog) is built once and threaded into the Haiku
82
101
  // caller. The caller prepends it on the first call only; --resume keeps it in session
83
102
  // history for all subsequent calls.
84
- const preamble = buildPreamble({ catalog });
103
+ const preamble = buildPreamble({ tools: toolList });
85
104
  const callHaiku = haikuCaller ?? createHaikuCaller({ preamble, timeoutMs: DEFAULT_HAIKU_TIMEOUT_MS });
86
105
 
87
106
  // Per-turn state, reset on turn_end.
@@ -158,7 +177,7 @@ export async function startDaemon({
158
177
  case 'turn_end':
159
178
  return handleTurnEnd(envelope.payload ?? {});
160
179
  case 'shutdown':
161
- setImmediate(() => shutdown(server, sessionId, logFn));
180
+ setImmediate(() => stop());
162
181
  return { stopping: true };
163
182
  default: {
164
183
  const err = new Error(`unknown event: ${envelope.event}`);
@@ -257,14 +276,53 @@ export async function startDaemon({
257
276
  const pidPath = pidFilePath(sessionId);
258
277
  await writeFile(pidPath, String(process.pid), 'utf8');
259
278
 
279
+ // v0.6.2: parent-process watch. SessionEnd is a graceful path; if Claude Code dies
280
+ // without it (crash, kill, IDE reload), nothing else cleans us up. Poll the parent
281
+ // PID and self-terminate on its disappearance.
282
+ let parentWatchHandle = null;
283
+ if (parentPid !== null) {
284
+ logFn(`watching parent pid=${parentPid} (interval=${parentWatchIntervalMs}ms)`);
285
+ parentWatchHandle = setInterval(() => {
286
+ if (!isProcessAlive(parentPid)) {
287
+ logFn(`parent pid=${parentPid} gone, shutting down`);
288
+ clearInterval(parentWatchHandle);
289
+ parentWatchHandle = null;
290
+ shutdown(server, sessionId, logFn).catch((err) => {
291
+ logFn(`parent-watch shutdown error: ${err.message}`);
292
+ });
293
+ }
294
+ }, parentWatchIntervalMs);
295
+ parentWatchHandle.unref();
296
+ }
297
+
298
+ const stop = async () => {
299
+ if (parentWatchHandle !== null) {
300
+ clearInterval(parentWatchHandle);
301
+ parentWatchHandle = null;
302
+ }
303
+ return shutdown(server, sessionId, logFn);
304
+ };
305
+
260
306
  return {
261
307
  server,
262
308
  path,
263
309
  pidPath,
264
- stop: () => shutdown(server, sessionId, logFn),
310
+ stop,
265
311
  };
266
312
  }
267
313
 
314
+ function isProcessAlive(pid) {
315
+ try {
316
+ process.kill(pid, 0);
317
+ return true;
318
+ } catch (err) {
319
+ if (err.code === 'ESRCH') return false;
320
+ if (err.code === 'EPERM') return true; // exists but owned by another user
321
+ // Unknown errno — be conservative and assume alive (don't auto-kill on transient).
322
+ return true;
323
+ }
324
+ }
325
+
268
326
  async function assertNoLiveDaemon(sessionId) {
269
327
  const pidPath = pidFilePath(sessionId);
270
328
  let raw;
@@ -62,12 +62,21 @@ const SHARED_HEADER = [
62
62
  // output contract, few-shot examples, and the tool catalog. The Anthropic session
63
63
  // retains this in history so subsequent --resume calls can judge with only a small
64
64
  // per-turn payload.
65
- export function buildPreamble({ catalog }) {
65
+ //
66
+ // v0.7.0: `tools` is an array of {name, description} pairs (the new tool-db format).
67
+ // `description` is the natural-language explanation supplied by the MCP server (or the
68
+ // hardcoded baseline for Claude Code built-in deferred tools). No schema, no usage —
69
+ // Haiku only needs to decide whether the tool should be called; "how to call" is Bell's
70
+ // responsibility (via ToolSearch).
71
+ export function buildPreamble({ tools }) {
72
+ if (!Array.isArray(tools)) {
73
+ throw new TypeError('buildPreamble: tools must be an array of {name, description}');
74
+ }
66
75
  return [
67
76
  SHARED_HEADER,
68
77
  '',
69
78
  '## カタログ',
70
- JSON.stringify(projectCatalog(catalog), null, 2),
79
+ JSON.stringify(tools, null, 2),
71
80
  ].join('\n');
72
81
  }
73
82
 
@@ -99,14 +108,6 @@ export function buildFinalStagePrompt({ userInput, usedTools, finalResponse }) {
99
108
  ].join('\n');
100
109
  }
101
110
 
102
- function projectCatalog(catalog) {
103
- return catalog.tools.map((t) => ({
104
- name: t.name,
105
- purpose: t.purpose,
106
- when_to_use: t.when_to_use,
107
- }));
108
- }
109
-
110
111
  // Parse Haiku's response. Throws HaikuError on schema violation.
111
112
  export function parseHaikuResponse(raw) {
112
113
  const trimmed = raw.trim();