claude-spotter 0.13.2 → 0.13.3

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.3
4
+
5
+ **カタログ外ツール名の推奨を遮断 (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 は参照していないため修正が届かない、という構造問題になる。
6
+
7
+ ### 変更点
8
+
9
+ - **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `SHARED_HEADER` に「name は**カタログに列挙されたツール名そのまま**のみ許可」ルールを明記。カタログ外 (Skill(xxx) / 任意スラッシュコマンド / 記憶した既知ツール) は禁止、該当なければ pass:true
10
+ - **編集 [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` 維持
11
+ - **編集 [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: ...`)
12
+ - **テスト追加**: `filterCatalogMisses` の 4 ケース (passthrough / 全 drop / 部分 drop / array 形式 catalog) + preamble 文言 smoke test + daemon 統合 2 ケース (全ハルシ → pass flip / 混在 → valid 残し)
13
+
14
+ ### 設計判断
15
+
16
+ - **prompt + filter の二重化**: prompt だけだと Haiku が従わない場合に素通りする。filter だけだと今後 preamble をいじる人が rule を外しても気付けない。両方ある方が安全
17
+ - **pass flip のセマンティクス**: 全 drop 時に pass:false のまま空配列を返すと v0.5.x で導入した schema 整合性チェック (`pass:false かつ missing_tools 空は inconsistent`) に引っかかる。`pass:true, reason='hallucination_filtered'` が正解
18
+ - **§0 silent fallback 禁止との関係**: これは「想定外を黙って潰す」ではなく「想定内の誤検出 = 記録 + 正常リターン」。dropped name はログに必ず残る
19
+
20
+ ### 残課題
21
+
22
+ - v0.13.0 新軸の**カタログ内過検出** (Read 乱発 / caveat 誤爆等) は別問題。[docs/open-issues.md](docs/open-issues.md) の P0 観測タスクとして継続
23
+ - few-shot 例の `current_time` は現 tool-db に無い名前。Haiku が cargo-cult するリスクを filter で潰したが、例そのものを実在ツールに差し替えるかは要検討 (ただし例の抽象性が失われる tradeoff あり)
24
+
3
25
  ## 0.13.2
4
26
 
5
27
  **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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- return { parsed: parseHaikuResponse(raw), meta };
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;