claude-spotter 0.13.2 → 1.0.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 +70 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/db-cmd.mjs +7 -3
- package/src/daemon/daemon.mjs +13 -1
- package/src/daemon/haiku-caller.mjs +30 -0
- package/src/index.mjs +2 -1
- package/src/tool-db/frontmatter.mjs +45 -0
- package/src/tool-db/investigate-agents.mjs +75 -0
- package/src/tool-db/investigate-skills.mjs +129 -0
- package/src/tool-db/lookup.mjs +1 -1
- package/src/tool-db/refresh.mjs +26 -12
- package/src/tool-db/deferred-baseline.mjs +0 -49
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
**監査対象をユーザー追加分 (MCP / スキル / サブエージェント) に絞り込み**。Claude Code 本体が提供するツール (即時 + 遅延) は監査カタログから全面除外。設計転換の major bump。
|
|
6
|
+
|
|
7
|
+
### 背景
|
|
8
|
+
|
|
9
|
+
v0.13.x までは「Claude Code 組込みの遅延ツール (WebSearch / TodoWrite 等 17 件) は Bell が呼び忘れやすい」という仮定で手書き baseline を保持していた。今回の設計会議で 2 点が判明し、前提自体を撤回:
|
|
10
|
+
|
|
11
|
+
1. **Bell は本体側ツールを使いこなしている**。WebSearch / WebFetch / NotebookEdit / Cron 系 / Worktree / 通知は自発率が十分高く、Spotter が提案すべき呼び忘れ対象ではない
|
|
12
|
+
2. **即時 / 遅延の境界は Claude Code バージョンで動的に変わる**。現セッションの実測で、`AskUserQuestion` / `TodoWrite` / `EnterPlanMode` / `ExitPlanMode` / `TaskOutput` / `TaskStop` は baseline に「遅延」と書かれているが実際には **即時ツール**として扱われていた。手書き baseline は構造的に drift するので追従は不可能
|
|
13
|
+
|
|
14
|
+
Spotter の役割は「Bell にとって**言われないと思い出さない** MCP / スキル / サブエージェントを視野に入れさせる」こと。本体側は Bell の手中にある。
|
|
15
|
+
|
|
16
|
+
### 変更点
|
|
17
|
+
|
|
18
|
+
- **削除 [src/tool-db/deferred-baseline.mjs](src/tool-db/deferred-baseline.mjs)**: 手書き 17 件の baseline を撤去。`DEFERRED_TOOL_BASELINE` / `getDeferredDescription` / `listDeferredNames` の export も削除 (破壊変更)
|
|
19
|
+
- **新規 [src/tool-db/frontmatter.mjs](src/tool-db/frontmatter.mjs)**: SKILL.md / agent .md の YAML frontmatter から `name` + `description` を抽出する最小パーサー (ゼロ依存)
|
|
20
|
+
- **新規 [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` から実体にアクセス
|
|
21
|
+
- **新規 [src/tool-db/investigate-agents.mjs](src/tool-db/investigate-agents.mjs)**: サブエージェントを同じく 3 scope から収集。名前は素の名前、衝突は project > user > plugin の優先順で解決
|
|
22
|
+
- **編集 [src/tool-db/refresh.mjs](src/tool-db/refresh.mjs)**: `buildInvestigationSnapshot` から deferred 経路を削除、スキル / サブエージェント経路を追加。MCP live fetch + `claude.ai` baseline (Gmail / Calendar / Drive) は維持
|
|
23
|
+
- **編集 [src/cli/db-cmd.mjs](src/cli/db-cmd.mjs)**: `spotter db rebuild` が local DB に加えて **global DB も wipe** するように仕様変更。旧バージョンから上がってきたユーザーが古い deferred エントリを抱えたままにならないため
|
|
24
|
+
- **編集 [src/index.mjs](src/index.mjs)**: `listSkillsAll` / `listActivePlugins` / `listAgentsAll` の export 追加
|
|
25
|
+
- **リネーム [docs/catalog-design-deferred-mcp.md](docs/catalog-design.md) → [docs/catalog-design.md](docs/catalog-design.md)**: 大幅書き直し。対象範囲・分類軸・収集経路を v1.0.0 仕様で更新
|
|
26
|
+
- **テスト更新 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: deferred baseline テスト 3 件削除、frontmatter テスト 3 件 + skill テスト 2 件 + agent テスト 2 件を追加。計 32 件全通過
|
|
27
|
+
|
|
28
|
+
### 結果
|
|
29
|
+
|
|
30
|
+
本プロジェクトで `buildInvestigationSnapshot` を走らせると **268 件 resolved**:
|
|
31
|
+
|
|
32
|
+
- MCP: 40 件 (caveat 6 + claude.ai Gmail/Calendar/Drive 25 + x-api 9)
|
|
33
|
+
- スキル (名前空間付き): 181 件 (ECC が大半)
|
|
34
|
+
- サブエージェント + bare スキル: 47 件 (ECC 38 agents + 他)
|
|
35
|
+
|
|
36
|
+
preamble 初回送信サイズ推定 15-25K tokens (Haiku 4.5 の 200K コンテキストに対し 12% 程度)。v0.6.0 preamble-once で 2 回目以降は per-turn delta のみ、セッション 1 回あたりの追加コストは初回のみ。
|
|
37
|
+
|
|
38
|
+
### 設計判断
|
|
39
|
+
|
|
40
|
+
- **ECC プラグインの全スキル (181 件) をそのまま投入**: 「使う可能性があるなら視野に入れる」が Spotter の本旨。description の semantic 判定は Haiku に任せる (preamble 肥大より recall 優先)
|
|
41
|
+
- **learned/ などの空ディレクトリは silent に素通り**: SKILL.md が無ければスキルではない、ENOENT だけログを抑制
|
|
42
|
+
- **名前衝突の解決**: スキルは `<plugin>:<name>` で namespace 分離されるので衝突せず。サブエージェントは bare name なので project > user > plugin 優先で Map 上書き
|
|
43
|
+
- **破壊変更を major bump で明示**: カタログ契約が変わる + 公開 export 削除 + db rebuild 仕様変更、の 3 点で semver major
|
|
44
|
+
|
|
45
|
+
### 残課題
|
|
46
|
+
|
|
47
|
+
- **初回 Haiku latency の観測**: 268 件 preamble で `duration_ms` が 45s timeout に接近しないか実運用で確認。超えるようなら再緩和か件数絞り込みを再検討
|
|
48
|
+
- **baseline 自動追従機構**: `claude.ai` MCP baseline は手書きのまま。Claude 側で追加があっても検知できない。長期的には監視機構が必要 ([docs/open-issues.md](docs/open-issues.md) P1 継続)
|
|
49
|
+
- **v0.13.0 新軸の過検出**: turn_end 軸 (ツール適用機会監査) の誤爆パターンは別問題、継続観測
|
|
50
|
+
|
|
51
|
+
## 0.13.3
|
|
52
|
+
|
|
53
|
+
**カタログ外ツール名の推奨を遮断 (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 は参照していないため修正が届かない、という構造問題になる。
|
|
54
|
+
|
|
55
|
+
### 変更点
|
|
56
|
+
|
|
57
|
+
- **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `SHARED_HEADER` に「name は**カタログに列挙されたツール名そのまま**のみ許可」ルールを明記。カタログ外 (Skill(xxx) / 任意スラッシュコマンド / 記憶した既知ツール) は禁止、該当なければ pass:true
|
|
58
|
+
- **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `filterCatalogMisses(parsed, catalogNames)` を export。parse 後の post-filter として、`missing_tools[].name` がカタログ外のエントリを drop する。全削除なら `pass=true, reason='hallucination_filtered'` に flip、部分削除なら valid 分だけ残し `pass=false` 維持
|
|
59
|
+
- **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: `startDaemon` が tool-db ロード時に `catalogNames = new Set(toolList.map(t => t.name))` を構築、`runHaikuJudgment` で `parseHaikuResponse` 後に filter を適用。drop した name はログに残す (`dropped catalog-external names: ...`)
|
|
60
|
+
- **テスト追加**: `filterCatalogMisses` の 4 ケース (passthrough / 全 drop / 部分 drop / array 形式 catalog) + preamble 文言 smoke test + daemon 統合 2 ケース (全ハルシ → pass flip / 混在 → valid 残し)
|
|
61
|
+
|
|
62
|
+
### 設計判断
|
|
63
|
+
|
|
64
|
+
- **prompt + filter の二重化**: prompt だけだと Haiku が従わない場合に素通りする。filter だけだと今後 preamble をいじる人が rule を外しても気付けない。両方ある方が安全
|
|
65
|
+
- **pass flip のセマンティクス**: 全 drop 時に pass:false のまま空配列を返すと v0.5.x で導入した schema 整合性チェック (`pass:false かつ missing_tools 空は inconsistent`) に引っかかる。`pass:true, reason='hallucination_filtered'` が正解
|
|
66
|
+
- **§0 silent fallback 禁止との関係**: これは「想定外を黙って潰す」ではなく「想定内の誤検出 = 記録 + 正常リターン」。dropped name はログに必ず残る
|
|
67
|
+
|
|
68
|
+
### 残課題
|
|
69
|
+
|
|
70
|
+
- v0.13.0 新軸の**カタログ内過検出** (Read 乱発 / caveat 誤爆等) は別問題。[docs/open-issues.md](docs/open-issues.md) の P0 観測タスクとして継続
|
|
71
|
+
- few-shot 例の `current_time` は現 tool-db に無い名前。Haiku が cargo-cult するリスクを filter で潰したが、例そのものを実在ツールに差し替えるかは要検討 (ただし例の抽象性が失われる tradeoff あり)
|
|
72
|
+
|
|
3
73
|
## 0.13.2
|
|
4
74
|
|
|
5
75
|
**Daemon の死因を必ずログに残す診断インフラ + Haiku 子プロセス stdio の防御的 error listener**。v0.13.1 までは daemon が `uncaughtException` / `unhandledRejection` で死ぬと痕跡ゼロで消えていた ([daemon-80b5c0af.log](../../.spotter/logs/daemon-80b5c0af-700f-47af-a3ac-796144823a7d.log) line 15 → line 16 で shutdown ログなしに再起動)。次に同じことが起きた時に真因を必ず捕まえられるよう、診断 handler を導入。
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Spotter
|
|
2
2
|
|
|
3
|
-
> **
|
|
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
package/src/cli/db-cmd.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
56
|
+
await saveDb(globalDbPath(), emptyDb());
|
|
57
|
+
process.stderr.write(`spotter db rebuild: cleared local + global DB, refreshing...\n`);
|
|
54
58
|
await runDbRefresh();
|
|
55
59
|
}
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
buildFinalStagePrompt,
|
|
39
39
|
buildPreamble,
|
|
40
40
|
parseHaikuResponse,
|
|
41
|
+
filterCatalogMisses,
|
|
41
42
|
createHaikuCaller,
|
|
42
43
|
HaikuError,
|
|
43
44
|
} from './haiku-caller.mjs';
|
|
@@ -103,6 +104,11 @@ export async function startDaemon({
|
|
|
103
104
|
}
|
|
104
105
|
logFn(`tool-db loaded: ${toolList.length} tools` + (projectRoot ? ` (project=${projectRoot})` : ''));
|
|
105
106
|
|
|
107
|
+
// v0.13.3: Haiku occasionally hallucinates tool names outside the catalog (training-memory
|
|
108
|
+
// leakage / few-shot cargo-cult). We filter these post-parse; entries not in this set are
|
|
109
|
+
// dropped. See filterCatalogMisses for the pass-flip semantics.
|
|
110
|
+
const catalogNames = new Set(toolList.map((t) => t.name));
|
|
111
|
+
|
|
106
112
|
// v0.6.0: preamble (role + schema + catalog) is built once and threaded into the Haiku
|
|
107
113
|
// caller. The caller prepends it on the first call only; --resume keeps it in session
|
|
108
114
|
// history for all subsequent calls.
|
|
@@ -133,8 +139,9 @@ export async function startDaemon({
|
|
|
133
139
|
// Other Haiku errors (timeout, spawn failure) still propagate — §14 unexpected → throw.
|
|
134
140
|
const runHaikuJudgment = async (stage, prompt) => {
|
|
135
141
|
const { raw, meta } = await callHaikuTracked(prompt);
|
|
142
|
+
let parsed;
|
|
136
143
|
try {
|
|
137
|
-
|
|
144
|
+
parsed = parseHaikuResponse(raw);
|
|
138
145
|
} catch (err) {
|
|
139
146
|
if (err instanceof HaikuError && err.code === 'E_HAIKU_SCHEMA') {
|
|
140
147
|
logFn(`${stage}: role collapse detected, session reset: ${err.message}`);
|
|
@@ -145,6 +152,11 @@ export async function startDaemon({
|
|
|
145
152
|
}
|
|
146
153
|
throw err;
|
|
147
154
|
}
|
|
155
|
+
const { parsed: filtered, dropped } = filterCatalogMisses(parsed, catalogNames);
|
|
156
|
+
if (dropped.length > 0) {
|
|
157
|
+
logFn(`${stage}: dropped catalog-external names: ${dropped.join(',')}`);
|
|
158
|
+
}
|
|
159
|
+
return { parsed: filtered, meta };
|
|
148
160
|
};
|
|
149
161
|
|
|
150
162
|
// v0.12.0: heartbeat. Reset on every envelope; if no event arrives within
|
|
@@ -51,6 +51,9 @@ const SHARED_HEADER = [
|
|
|
51
51
|
'{"pass": <true|false>, "missing_tools": [{"name": "<カタログ名>", "reason": "<一文の日本語>"}]}',
|
|
52
52
|
'- pass:true なら missing_tools は空、pass:false なら 1 件以上',
|
|
53
53
|
'- JSON のみ。前置き・コードフェンス禁止',
|
|
54
|
+
'- **name は後述「## カタログ」に列挙されたツール名そのまま**のみ許可。',
|
|
55
|
+
' カタログ外の名前 (Skill(xxx) / 任意のスラッシュコマンド / 記憶した既知ツール等) は禁止。',
|
|
56
|
+
' 該当するツールがカタログに見当たらなければ、無理に挙げず pass:true を返す。',
|
|
54
57
|
'',
|
|
55
58
|
'## 判定対象',
|
|
56
59
|
'各ターン、以下いずれかの stage で判定リクエストを受けます:',
|
|
@@ -179,6 +182,33 @@ export function parseHaikuResponse(raw) {
|
|
|
179
182
|
return parsed;
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
// v0.13.3: post-parse defence against catalog-external hallucinations. Haiku occasionally
|
|
186
|
+
// proposes tool names that are not in the catalog — training-memory leakage or few-shot
|
|
187
|
+
// cargo-culting. The SHARED_HEADER now forbids this explicitly, but we also filter
|
|
188
|
+
// defensively: entries whose name is not in `catalogNames` are dropped. If all entries are
|
|
189
|
+
// dropped, pass is flipped to true with reason='hallucination_filtered'. Mixed cases keep
|
|
190
|
+
// the valid entries and stay pass=false.
|
|
191
|
+
//
|
|
192
|
+
// Returns { parsed, dropped } where `dropped` is the list of filtered-out names (for
|
|
193
|
+
// observability / logging).
|
|
194
|
+
export function filterCatalogMisses(parsed, catalogNames) {
|
|
195
|
+
const names = catalogNames instanceof Set ? catalogNames : new Set(catalogNames);
|
|
196
|
+
const kept = [];
|
|
197
|
+
const dropped = [];
|
|
198
|
+
for (const m of parsed.missing_tools) {
|
|
199
|
+
if (names.has(m.name)) kept.push(m);
|
|
200
|
+
else dropped.push(m.name);
|
|
201
|
+
}
|
|
202
|
+
if (dropped.length === 0) return { parsed, dropped };
|
|
203
|
+
if (kept.length === 0) {
|
|
204
|
+
return {
|
|
205
|
+
parsed: { pass: true, missing_tools: [], reason: 'hallucination_filtered' },
|
|
206
|
+
dropped,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return { parsed: { ...parsed, missing_tools: kept }, dropped };
|
|
210
|
+
}
|
|
211
|
+
|
|
182
212
|
function stripFence(text) {
|
|
183
213
|
const fenceMatch = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
|
|
184
214
|
return fenceMatch ? fenceMatch[1] : text;
|
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 {
|
|
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
|
+
}
|
package/src/tool-db/lookup.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// tool-db: 3-tier lookup with write-through and drift correction.
|
|
2
2
|
//
|
|
3
|
-
// Per docs/catalog-design
|
|
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
|
package/src/tool-db/refresh.mjs
CHANGED
|
@@ -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
|
|
4
|
-
// 1. enumerate available tool names (MCP
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
}
|