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 +75 -0
- package/README.md +8 -7
- package/bin/spotter.mjs +9 -7
- package/package.json +1 -2
- package/src/cli/daemon-cmd.mjs +18 -3
- package/src/cli/db-cmd.mjs +55 -0
- package/src/cli/doctor.mjs +26 -7
- package/src/cli/install.mjs +10 -15
- package/src/cli/uninstall.mjs +1 -1
- package/src/daemon/daemon.mjs +68 -10
- package/src/daemon/haiku-caller.mjs +11 -10
- package/src/hooks/session-start.mjs +24 -8
- package/src/index.mjs +5 -3
- package/src/tool-db/deferred-baseline.mjs +49 -0
- package/src/tool-db/investigate-mcp.mjs +233 -0
- package/src/tool-db/loader.mjs +86 -0
- package/src/tool-db/lookup.mjs +87 -0
- package/src/tool-db/refresh.mjs +64 -0
- package/src/version.mjs +1 -1
- package/src/catalog/lint.mjs +0 -56
- package/src/catalog/loader.mjs +0 -36
- package/src/catalog/schema.mjs +0 -109
- package/src/cli/catalog.mjs +0 -39
- package/templates/tools.yaml +0 -111
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.
|
|
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
|
|
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-
|
|
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
|
|
65
|
-
spotter
|
|
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
|
|
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 改訂とセットで今後検討
|
|
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 {
|
|
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
|
|
30
|
-
spotter
|
|
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 '
|
|
68
|
+
case 'db': {
|
|
68
69
|
const sub = rest[0];
|
|
69
|
-
if (sub === '
|
|
70
|
-
if (sub === '
|
|
71
|
-
|
|
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.
|
|
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"
|
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -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 {
|
|
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 ['
|
|
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
|
-
//
|
|
48
|
-
const catalogPath = join(home, 'tool-catalog', 'tools.yaml');
|
|
48
|
+
// tool-db (global)
|
|
49
49
|
try {
|
|
50
|
-
const
|
|
51
|
-
|
|
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, '
|
|
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)`);
|
package/src/cli/install.mjs
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// `spotter install` — create ~/.spotter/,
|
|
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
|
|
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.
|
|
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:
|
|
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) {
|
package/src/cli/uninstall.mjs
CHANGED
|
@@ -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/ (
|
|
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) {
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -35,18 +35,21 @@ import {
|
|
|
35
35
|
createHaikuCaller,
|
|
36
36
|
HaikuError,
|
|
37
37
|
} from './haiku-caller.mjs';
|
|
38
|
-
import {
|
|
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
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
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({
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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(
|
|
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();
|