claude-spotter 0.13.1 → 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 +42 -0
- package/package.json +1 -1
- package/src/cli/daemon-cmd.mjs +27 -4
- package/src/daemon/daemon.mjs +13 -1
- package/src/daemon/haiku-caller.mjs +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
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
|
+
|
|
25
|
+
## 0.13.2
|
|
26
|
+
|
|
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 を導入。
|
|
28
|
+
|
|
29
|
+
### 監査の経過と結論
|
|
30
|
+
|
|
31
|
+
[haiku-caller.mjs](src/daemon/haiku-caller.mjs) の `child.stdin.end(prompt)` → timeout で `child.kill()` という流れで、未 flush の stdin が EPIPE を emit → unhandled stream error → daemon 即死、という仮説を立てた。Windows + Node v24.14.0 で repro script を書いて検証したところ、**`child.stdin/stdout/stderr` の error listener 不在でも uncaughtException は発火せず process は生存** (stdin 8KB end → 500ms 後に kill → 4 秒生存して clean exit、EXIT=0)。
|
|
32
|
+
|
|
33
|
+
つまり 80b5c0af の死因は stdin EPIPE ではなく、別経路。ログには `handler error on turn_end: E_HAIKU_TIMEOUT` (transport の catch + onError まで完了) が残っているので、その後の何かで死んでいる。**真因を確定できる証拠がログにないことが本質的な問題**と判断、診断 handler を先に入れる方針に切替えた。
|
|
34
|
+
|
|
35
|
+
### 変更点
|
|
36
|
+
|
|
37
|
+
- **編集 [src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: daemon 起動冒頭で `process.on('uncaughtException')` / `process.on('unhandledRejection')` を登録。**同期 `writeFileSync` で log file に append** してから `process.exit(1)`。async write はバッファ flush 前に exit して line を失うが、sync write なら必ず残る。次回死亡時に stack trace + 種別 (`uncaughtException` か `unhandledRejection` か) が確実に記録される
|
|
38
|
+
- **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `child.stdin/stdout/stderr` に no-op error listener を追加。今の Node v24 では落ちないと実証済みだが、Node 公式 docs はこの edge case を明示保証していない (将来の Node 変更や別 OS で挙動が変わる可能性) ため、defensive coding として投入
|
|
39
|
+
|
|
40
|
+
### 残課題
|
|
41
|
+
|
|
42
|
+
- daemon 突然死の真因特定: **次回再現を待つ**。診断 handler が入ったので、次に死亡した時はログに必ず痕跡が残る。それを見て対処する
|
|
43
|
+
- v0.13.1 で 30s → 45s 緩和した Haiku timeout の効果観測は継続: 現セッション [daemon-69bd2b93.log](../../.spotter/logs/daemon-69bd2b93-ffbe-43bc-94e7-1d0ba2bd9e74.log) line 5 で `mode=first, duration_ms=32703` を観測 (30s 設定なら timeout していた値が 45s で生存)。サンプル 1 件で結論はまだ早い
|
|
44
|
+
|
|
3
45
|
## 0.13.1
|
|
4
46
|
|
|
5
47
|
**Haiku timeout 30s → 45s 緩和 + hook 側 IPC timeout を整合**。v0.13.0 以前の実セッション ([daemon-80b5c0af.log](../../.spotter/logs/daemon-80b5c0af-700f-47af-a3ac-796144823a7d.log) line 15) で `E_HAIKU_TIMEOUT: haiku did not respond within 30000ms` を観測。同ログ line 20 でも `mode=first, duration_ms=20948` と 30s の 70% 域まで達しており、timeout が実測レイテンシに対して狭すぎた。合わせて [src/hooks/stop.mjs](src/hooks/stop.mjs) の IPC timeout が元々 15s で Haiku 側 30s と整合していなかった既存バグ (turn_end で Haiku が 16s 超かかると hook 側が先に諦めていた) も同時解消。
|
package/package.json
CHANGED
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { startDaemon, DaemonAlreadyRunningError } from '../daemon/daemon.mjs';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { open } from 'node:fs/promises';
|
|
7
|
+
import { writeFileSync } from 'node:fs';
|
|
7
8
|
|
|
8
9
|
function parseArgs(argv) {
|
|
9
10
|
const out = { sessionId: null, projectRoot: null };
|
|
@@ -30,10 +31,32 @@ export async function runDaemonStart({ argv }) {
|
|
|
30
31
|
process.exit(2);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
34
|
+
const logFilePath = join(homedir(), '.spotter', 'logs', `daemon-${sessionId}.log`);
|
|
35
|
+
|
|
36
|
+
// v0.13.2: last-resort fatal handlers. Without these, an uncaughtException or
|
|
37
|
+
// unhandledRejection silently kills the daemon and the regular logFile.write()
|
|
38
|
+
// (async) loses the trailing line on sudden death — leaving zero forensic
|
|
39
|
+
// trace. We do a sync append so the cause is always captured before exit.
|
|
40
|
+
// See open-issues.md "daemon プロセスが shutdown ログなしに死ぬ".
|
|
41
|
+
const fatalLog = (kind, err) => {
|
|
42
|
+
const detail = err && err.stack ? err.stack : String(err);
|
|
43
|
+
const line = `[${new Date().toISOString()}] FATAL ${kind}: ${detail}\n`;
|
|
44
|
+
try {
|
|
45
|
+
writeFileSync(logFilePath, line, { flag: 'a' });
|
|
46
|
+
} catch {
|
|
47
|
+
process.stderr.write(line);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
process.on('uncaughtException', (err) => {
|
|
51
|
+
fatalLog('uncaughtException', err);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
54
|
+
process.on('unhandledRejection', (reason) => {
|
|
55
|
+
fatalLog('unhandledRejection', reason);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const logFile = await open(logFilePath, 'a');
|
|
37
60
|
const log = (msg) => {
|
|
38
61
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
39
62
|
logFile.write(line).catch(() => {});
|
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;
|
|
@@ -242,6 +272,16 @@ export function createHaikuCaller({ preamble, timeoutMs, claudeBin = 'claude', m
|
|
|
242
272
|
let stderr = '';
|
|
243
273
|
let settled = false;
|
|
244
274
|
|
|
275
|
+
// v0.13.2: absorb EPIPE/ECONNRESET on stdio streams. We end(prompt) and
|
|
276
|
+
// then kill() on timeout — if the write hadn't drained yet, the unflushed
|
|
277
|
+
// stream can emit 'error' after kill. Today's Node doesn't crash on
|
|
278
|
+
// unhandled stdin errors, but the docs don't guarantee that, and this
|
|
279
|
+
// listener removes a potential silent-death path.
|
|
280
|
+
const noop = () => {};
|
|
281
|
+
child.stdin.on('error', noop);
|
|
282
|
+
child.stdout.on('error', noop);
|
|
283
|
+
child.stderr.on('error', noop);
|
|
284
|
+
|
|
245
285
|
const timer = setTimeout(() => {
|
|
246
286
|
if (settled) return;
|
|
247
287
|
settled = true;
|