claude-spotter 0.11.1 → 0.13.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 +52 -0
- package/package.json +1 -1
- package/src/cli/daemon-cmd.mjs +4 -10
- package/src/daemon/daemon.mjs +41 -44
- package/src/daemon/haiku-caller.mjs +33 -9
- package/src/hooks/session-start.mjs +7 -74
- package/src/hooks/spawn-daemon.mjs +72 -0
- package/src/hooks/user-prompt.mjs +39 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
**Stop 判定軸を「要請充足チェック」から「ツール適用機会の監査」に転換**。v0.12.x 以前の `stage=turn_end` は `<user_input>` + `<used_tools>` + `<final_response>` を Haiku に渡し、「ユーザー要請されたツールが使われたか」を判定していた。この軸では Bell が Stop 到達後にすべき動作 — 事実断定の裏付け / 新知見の記録 / 既知情報の照会 — を拾えない。実セッションで Haiku が応答「判明: A モジュールは B に依存」に対し `caveat_record` を推奨する、といった本来期待される指摘が構造的に出ない状態だった (ユーザーが [この議論](https://github.com/kitepon-rgb/Spotter) で指摘)。
|
|
6
|
+
|
|
7
|
+
### 新軸: ツール適用機会の監査
|
|
8
|
+
|
|
9
|
+
- **入力**: `<used_tools>` + `<final_response>` のみ (user_input は削除)
|
|
10
|
+
- **問い**: 「この応答で、カタログ上のツールが役立つ箇所ないか?」
|
|
11
|
+
- **3 カテゴリ**: 検証 (Read/Grep/Bash/WebFetch 等) / 登録 (memory/caveat 等) / 照会 (search/list 等)
|
|
12
|
+
- **非対称**: 指摘ゼロは歓迎、`used_tools` 既含は再指摘しない、迷ったら pass:true
|
|
13
|
+
|
|
14
|
+
挨拶ターンの早期 pass は daemon 側の `state.lastUserInput === null` 分岐で従来通り機能する (user_input が来ていないターンは turn_end で `reason=no_user_input` で pass)。Stop hook の入力契約 (`final_response` のみを daemon に送信) は v0.4.4 時点で user_input を送っていないので hook 側の変更なし。
|
|
15
|
+
|
|
16
|
+
### 変更点
|
|
17
|
+
|
|
18
|
+
- **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `SHARED_HEADER` の stage=turn_end 説明を書き換え、few-shot を 4 件 (検証/登録/照会/pass) に拡張。`buildFinalStagePrompt` から `userInput` 引数を削除、`<user_input>` タグも削除
|
|
19
|
+
- **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: `handleTurnEnd` の `buildFinalStagePrompt` 呼び出しから `userInput` 引数削除、`savedUserInput` 変数削除。`state.lastUserInput` は `no_user_input` pass 分岐用に保持継続 (コメントで意図明記)
|
|
20
|
+
- **編集 [test/haiku-caller.test.mjs](test/haiku-caller.test.mjs)**: `buildFinalStagePrompt` の 3 テストから `userInput:` 引数削除、`<user_input>` タグ包含アサートを非包含アサートに反転
|
|
21
|
+
- **編集 [test/daemon.test.mjs](test/daemon.test.mjs)**: turn_end の per-turn prompt に `<user_input>` タグも user 発言原文も含まれないことを確認するテストを 1 件追加
|
|
22
|
+
- **編集 [CLAUDE.md](CLAUDE.md)**: Product Concept に「判定軸 (v0.13.0 で 2 軸化)」セクションを追加、user_input=要請充足チェック / turn_end=ツール適用機会の監査 を明記
|
|
23
|
+
|
|
24
|
+
### 非互換
|
|
25
|
+
|
|
26
|
+
- **判定挙動の意味論変更**: v0.12.x までの「user_input 要請に対応するツール」しか指摘しなかった Stop hook が、v0.13.0 からは user_input 非依存で「応答に対する適用機会」を指摘する。false positive / false negative の方向性も変わるため、過検出率 / pass 率の再計測が必要 ([docs/open-issues.md](docs/open-issues.md) P0 に観測タスクを追加)
|
|
27
|
+
- **API 変更**: `buildFinalStagePrompt({ userInput, usedTools, finalResponse })` → `buildFinalStagePrompt({ usedTools, finalResponse })`。外部から直接呼ばれる API ではない (daemon 内部) ため影響範囲は Spotter 本体のみ
|
|
28
|
+
|
|
29
|
+
## 0.12.0
|
|
30
|
+
|
|
31
|
+
**親 PID watch を heartbeat 方式に置換 + UserPromptSubmit auto-resurrect**。v0.6.2 で導入した `--parent-pid` watch (Claude Code 本体 PID を `process.kill(pid, 0)` で 5 秒間隔 ping) が VSCode native extension 環境で誤爆する問題を解消。`process.ppid` は extension host から spawn される短命ラッパーを指していて、5 秒で ESRCH → daemon 自死していた (`~/.spotter/logs/ppid-probe.log` の env dump で実測: hook の ppid が毎回 (55692, 46020 等) 変わるのに対し `VSCODE_PID=39964` は固定、CLAUDE_* 系には PID 系 env なし)。
|
|
32
|
+
|
|
33
|
+
### 設計
|
|
34
|
+
|
|
35
|
+
- **heartbeat 方式 (b 案)**: daemon が envelope を受信するたびに `setTimeout(selfShutdown, 30min)` を `clearTimeout` + 再 set。ポーリングではなく event-driven で CPU 負荷ゼロ、検出は精密。30 分を超える Claude Code 沈黙は通常の使用では発生しない閾値
|
|
36
|
+
- **OS / 環境依存ゼロ**: VSCODE_PID / CLAUDE_*_PID 等の探索が不要、CLI / native extension / 将来の他クライアント全てで同一挙動
|
|
37
|
+
- **auto-resurrect**: UserPromptSubmit hook が `E_UNREACHABLE` (socket 不在) を検出したら spawn + readiness 待ち + retry。daemon が 30 分 timeout で死んでも crash していても、次のユーザー入力で自動復活する。「daemon が死んでたら pass」(§0 silent fallback 違反) ではなく「daemon が死んでたら起こす」で対処
|
|
38
|
+
- **PreToolUse / Stop は復活させない**: turn の途中で daemon が居なかった場合、used_tools 欠落・preamble 未送信の歪んだ状態で監査再開すると誤検出が増える。次の UserPromptSubmit (新 turn の起点) で復活する設計
|
|
39
|
+
|
|
40
|
+
### 変更点
|
|
41
|
+
|
|
42
|
+
- **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: parent-pid watch (`setInterval` + `process.kill(pid, 0)`) を削除、`heartbeatTimeoutMs` パラメータ + `resetHeartbeat()` (clearTimeout + setTimeout の per-envelope re-arm) に置換。`isProcessAlive` も削除 (status.mjs に独立コピー有り)
|
|
43
|
+
- **編集 [src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: `--parent-pid` パース削除、`startDaemon` 呼び出しから `parentPid` 削除
|
|
44
|
+
- **新規 [src/hooks/spawn-daemon.mjs](src/hooks/spawn-daemon.mjs)**: spawn detached + readiness poll を session-start / user-prompt の両方から使えるように共通化
|
|
45
|
+
- **編集 [src/hooks/session-start.mjs](src/hooks/session-start.mjs)**: spawn ロジックを spawn-daemon.mjs に委譲、`--parent-pid` 渡し削除
|
|
46
|
+
- **編集 [src/hooks/user-prompt.mjs](src/hooks/user-prompt.mjs)**: `sendRequest` が `E_UNREACHABLE` で失敗したら `spawnDaemonAndWaitReady` を呼んで retry (1 回のみ)
|
|
47
|
+
- **削除 [src/hooks/ppid-probe.mjs]**: env dump 用の調査 hook、役目終了
|
|
48
|
+
- **編集 [.claude/settings.json](.claude/settings.json)**: probe hook 登録撤去
|
|
49
|
+
- **編集 [test/daemon.test.mjs](test/daemon.test.mjs)**: parent-watch test 2 件を削除、heartbeat timeout / heartbeat reset / heartbeatTimeoutMs validation の 3 件を追加
|
|
50
|
+
|
|
51
|
+
### 非互換
|
|
52
|
+
|
|
53
|
+
- `startDaemon({parentPid, parentWatchIntervalMs})` → `startDaemon({heartbeatTimeoutMs})`: API 変更。CLI の `--parent-pid` 引数も廃止 (受け取らなくなる)。Spotter は hook + daemon を同一 npm package で配布するため `npm install -g claude-spotter@latest` で一括更新すれば混在は起きない
|
|
54
|
+
|
|
3
55
|
## 0.11.1
|
|
4
56
|
|
|
5
57
|
**hotfix: `src/version.mjs` を `package.json` から読み取る**。0.11.0 は package.json を 0.11.0 に bump したが `src/version.mjs` のハードコード文字列 (`'0.10.0'`) を上げ忘れていたため、`spotter --version` が `0.10.0` のまま表示される不整合があった。同じミスを防ぐため ESM JSON import (`import pkg from '../package.json' with { type: 'json' }`) で package.json から動的に引くよう変更。以降は package.json の version を bump するだけで CLI 出力も追従する。
|
package/package.json
CHANGED
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -6,15 +6,11 @@ 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, 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
14
|
} else if (argv[i] === '--project-root') {
|
|
19
15
|
out.projectRoot = argv[i + 1];
|
|
20
16
|
i += 1;
|
|
@@ -24,7 +20,7 @@ function parseArgs(argv) {
|
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
export async function runDaemonStart({ argv }) {
|
|
27
|
-
const { sessionId,
|
|
23
|
+
const { sessionId, projectRoot } = parseArgs(argv);
|
|
28
24
|
if (!sessionId) {
|
|
29
25
|
process.stderr.write('spotter daemon start: --session-id is required\n');
|
|
30
26
|
process.exit(2);
|
|
@@ -48,11 +44,9 @@ export async function runDaemonStart({ argv }) {
|
|
|
48
44
|
// v0.5.0: no warmup. Session-scoped Haiku (--resume on follow-ups) pays cold-start
|
|
49
45
|
// only on the first real call; warmup added complexity for marginal benefit and is
|
|
50
46
|
// removed along with the stateless regime that required it.
|
|
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
47
|
// v0.7.0: projectRoot drives tool-db loading (replaces the old tools.yaml catalog).
|
|
55
|
-
|
|
48
|
+
// v0.12.0: orphan-cleanup is heartbeat-based inside startDaemon (no parent-PID arg).
|
|
49
|
+
running = await startDaemon({ sessionId, projectRoot, logFn: log });
|
|
56
50
|
} catch (err) {
|
|
57
51
|
if (err instanceof DaemonAlreadyRunningError) {
|
|
58
52
|
// v0.2 PID-preexist layer: a sibling daemon already serves this session.
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
// §5.7: event dispatch follows the envelope contract.
|
|
17
17
|
// §14: unexpected errors are thrown; hooks convert them to exit codes.
|
|
18
18
|
//
|
|
19
|
+
// v0.12.0: orphan-cleanup is now heartbeat-based instead of parent-PID watch. Every
|
|
20
|
+
// envelope (including readiness) resets a setTimeout; if no hook event arrives within
|
|
21
|
+
// HEARTBEAT_TIMEOUT_MS (30 min), the daemon self-shuts. Replaces v0.6.2's --parent-pid
|
|
22
|
+
// scheme, which mis-fired in VSCode native-extension environments where process.ppid
|
|
23
|
+
// pointed at a short-lived wrapper. UserPromptSubmit hook auto-resurrects a dead daemon.
|
|
24
|
+
//
|
|
19
25
|
// v0.2 defence layers against daemon proliferation (see plan §18) — still active:
|
|
20
26
|
// - SPOTTER_PARENT_PID env var (set by haiku-caller when spawning claude -p; hooks skip on presence)
|
|
21
27
|
// - agent_id gate (subagent hooks exit 0 before reaching the daemon)
|
|
@@ -45,11 +51,11 @@ const DEFAULT_HAIKU_CALL_WINDOW_MS = 10_000;
|
|
|
45
51
|
// being excessive, and a role-collapse recovery cycle (reset → next call is effectively
|
|
46
52
|
// a cold start again) stays within budget.
|
|
47
53
|
const DEFAULT_HAIKU_TIMEOUT_MS = 30_000;
|
|
48
|
-
// v0.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const
|
|
54
|
+
// v0.12.0: heartbeat-based orphan cleanup. Every envelope resets a setTimeout; if no
|
|
55
|
+
// hook event arrives within this window, the daemon self-shuts. 30 min is the longest
|
|
56
|
+
// silence we expect from a live Claude Code session. UserPromptSubmit auto-resurrects
|
|
57
|
+
// a dead daemon, so over-aggressive timeout would just cause a visible re-spawn.
|
|
58
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
53
59
|
|
|
54
60
|
export class DaemonAlreadyRunningError extends Error {
|
|
55
61
|
constructor(sessionId, pid) {
|
|
@@ -67,14 +73,13 @@ export async function startDaemon({
|
|
|
67
73
|
haikuCaller,
|
|
68
74
|
logFn = () => {},
|
|
69
75
|
haikuCallWindowMs = DEFAULT_HAIKU_CALL_WINDOW_MS,
|
|
70
|
-
|
|
71
|
-
parentWatchIntervalMs = DEFAULT_PARENT_WATCH_INTERVAL_MS,
|
|
76
|
+
heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
|
72
77
|
} = {}) {
|
|
73
78
|
if (!sessionId) {
|
|
74
79
|
throw new TypeError('sessionId is required');
|
|
75
80
|
}
|
|
76
|
-
if (
|
|
77
|
-
throw new TypeError('
|
|
81
|
+
if (!Number.isFinite(heartbeatTimeoutMs) || heartbeatTimeoutMs <= 0) {
|
|
82
|
+
throw new TypeError('heartbeatTimeoutMs must be a positive number');
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
await ensureRuntimeDir();
|
|
@@ -141,7 +146,24 @@ export async function startDaemon({
|
|
|
141
146
|
}
|
|
142
147
|
};
|
|
143
148
|
|
|
149
|
+
// v0.12.0: heartbeat. Reset on every envelope; if no event arrives within
|
|
150
|
+
// heartbeatTimeoutMs the daemon self-shuts. Replaces v0.6.2 parent-PID watch.
|
|
151
|
+
// The timer itself is created after server.listen() succeeds (see below).
|
|
152
|
+
let heartbeatHandle = null;
|
|
153
|
+
const resetHeartbeat = () => {
|
|
154
|
+
if (heartbeatHandle !== null) clearTimeout(heartbeatHandle);
|
|
155
|
+
heartbeatHandle = setTimeout(() => {
|
|
156
|
+
heartbeatHandle = null;
|
|
157
|
+
logFn(`heartbeat timeout (${heartbeatTimeoutMs}ms), shutting down`);
|
|
158
|
+
shutdown(server, sessionId, logFn).catch((err) => {
|
|
159
|
+
logFn(`heartbeat shutdown error: ${err.message}`);
|
|
160
|
+
});
|
|
161
|
+
}, heartbeatTimeoutMs);
|
|
162
|
+
heartbeatHandle.unref();
|
|
163
|
+
};
|
|
164
|
+
|
|
144
165
|
const handler = async (envelope) => {
|
|
166
|
+
resetHeartbeat();
|
|
145
167
|
if (!envelope || typeof envelope !== 'object') {
|
|
146
168
|
const err = new Error('invalid envelope');
|
|
147
169
|
err.code = 'E_INTERNAL';
|
|
@@ -236,12 +258,13 @@ export async function startDaemon({
|
|
|
236
258
|
return { pass: true, missing_tools: [], reason: 'no_user_input' };
|
|
237
259
|
}
|
|
238
260
|
|
|
239
|
-
|
|
261
|
+
// v0.13.0: state.lastUserInput は turn_end の Haiku 判定には渡さない (新軸は
|
|
262
|
+
// final_response + used_tools のみで判定)。ただし「挨拶ターン (user_input が来て
|
|
263
|
+
// いない) は早期 pass」の分岐は上で使うので保存は引き続き必要。
|
|
240
264
|
const savedUsedTools = state.usedTools.slice();
|
|
241
265
|
const { parsed, meta } = await runHaikuJudgment(
|
|
242
266
|
'turn_end',
|
|
243
267
|
buildFinalStagePrompt({
|
|
244
|
-
userInput: savedUserInput,
|
|
245
268
|
usedTools: savedUsedTools,
|
|
246
269
|
finalResponse,
|
|
247
270
|
})
|
|
@@ -276,29 +299,15 @@ export async function startDaemon({
|
|
|
276
299
|
const pidPath = pidFilePath(sessionId);
|
|
277
300
|
await writeFile(pidPath, String(process.pid), 'utf8');
|
|
278
301
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
}
|
|
302
|
+
// Start the heartbeat. The first hook event (typically SessionStart's readiness
|
|
303
|
+
// ping moments later) will reset it; if nothing arrives in heartbeatTimeoutMs we self-shut.
|
|
304
|
+
logFn(`heartbeat armed (timeout=${heartbeatTimeoutMs}ms)`);
|
|
305
|
+
resetHeartbeat();
|
|
297
306
|
|
|
298
307
|
const stop = async () => {
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
308
|
+
if (heartbeatHandle !== null) {
|
|
309
|
+
clearTimeout(heartbeatHandle);
|
|
310
|
+
heartbeatHandle = null;
|
|
302
311
|
}
|
|
303
312
|
return shutdown(server, sessionId, logFn);
|
|
304
313
|
};
|
|
@@ -311,18 +320,6 @@ export async function startDaemon({
|
|
|
311
320
|
};
|
|
312
321
|
}
|
|
313
322
|
|
|
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
|
-
|
|
326
323
|
async function assertNoLiveDaemon(sessionId) {
|
|
327
324
|
const pidPath = pidFilePath(sessionId);
|
|
328
325
|
let raw;
|
|
@@ -38,6 +38,11 @@ export async function ensureWorkdir() {
|
|
|
38
38
|
|
|
39
39
|
// Shared header covers BOTH stages — the preamble documents stage=user_input and
|
|
40
40
|
// stage=turn_end so per-turn prompts only need to announce which stage they are.
|
|
41
|
+
//
|
|
42
|
+
// v0.13.0: stage=turn_end の判定軸を「要請充足チェック」から「ツール適用機会の監査」に転換。
|
|
43
|
+
// 旧軸は <user_input> に対し used_tools が足りているかをチェックしていた。新軸は
|
|
44
|
+
// <final_response> の内容 (事実断定 / 記録すべき新情報 / 既知情報の参照) に対し、カタログ
|
|
45
|
+
// 上のツール (検証 / 登録 / 照会) を差し込める余地を探す。非対称 (指摘ゼロ歓迎) 設計。
|
|
41
46
|
const SHARED_HEADER = [
|
|
42
47
|
'あなたは Spotter。Bell (主役の Claude) が呼び忘れるツールを検出する監査役です。',
|
|
43
48
|
'ユーザーへの会話文は生成せず、必ず下記 JSON のみを返します。',
|
|
@@ -49,13 +54,32 @@ const SHARED_HEADER = [
|
|
|
49
54
|
'',
|
|
50
55
|
'## 判定対象',
|
|
51
56
|
'各ターン、以下いずれかの stage で判定リクエストを受けます:',
|
|
52
|
-
'
|
|
53
|
-
'
|
|
54
|
-
'
|
|
57
|
+
'',
|
|
58
|
+
'### stage=user_input',
|
|
59
|
+
'<user_input> のみ届く。when_to_use に明確に該当するツールを列挙。',
|
|
60
|
+
'推測禁止。該当なしなら pass:true。',
|
|
61
|
+
'',
|
|
62
|
+
'### stage=turn_end (ツール適用機会の監査)',
|
|
63
|
+
'<final_response> + <used_tools> が届く。',
|
|
64
|
+
'Bell の応答に含まれる動作 — 事実の断定 / 記録すべき新情報 / 既知情報の参照 —',
|
|
65
|
+
'それぞれについて、カタログに役立つツールがあれば提示する。',
|
|
66
|
+
'検証 (Read/Grep/Bash/WebFetch 等) / 登録 (memory/caveat 等) / 照会 (search/list 等) のいずれも対象。',
|
|
67
|
+
'<used_tools> に既に含まれるツールは再指摘しない。',
|
|
68
|
+
'指摘ゼロは歓迎。迷ったら pass:true。',
|
|
55
69
|
'',
|
|
56
70
|
'## 例',
|
|
57
|
-
'- stage=user_input "今何時?"
|
|
58
|
-
'
|
|
71
|
+
'- stage=user_input "今何時?"',
|
|
72
|
+
' → {"pass":false,"missing_tools":[{"name":"current_time","reason":"時刻の直接質問"}]}',
|
|
73
|
+
'- stage=user_input "ありがとう"',
|
|
74
|
+
' → {"pass":true,"missing_tools":[]}',
|
|
75
|
+
'- stage=turn_end 応答「この関数は配列長を返します」(used:なし) ← 検証',
|
|
76
|
+
' → {"pass":false,"missing_tools":[{"name":"Read","reason":"関数実装の断定は実ファイル読取で裏付けるべき"}]}',
|
|
77
|
+
'- stage=turn_end 応答「判明: A モジュールは B に依存」(used:Grep) ← 登録',
|
|
78
|
+
' → {"pass":false,"missing_tools":[{"name":"mcp__caveat__caveat_record","reason":"新発見の依存関係は記録して次回参照可能にすべき"}]}',
|
|
79
|
+
'- stage=turn_end 応答「この話題は前にも議論したはず」(used:なし) ← 照会',
|
|
80
|
+
' → {"pass":false,"missing_tools":[{"name":"mcp__caveat__caveat_search","reason":"過去の議論参照は検索して裏付けるべき"}]}',
|
|
81
|
+
'- stage=turn_end 応答「作業完了しました」(used:Read,Edit,Bash) ← pass',
|
|
82
|
+
' → {"pass":true,"missing_tools":[]}',
|
|
59
83
|
].join('\n');
|
|
60
84
|
|
|
61
85
|
// Preamble — sent exactly once per Haiku session (first call). Contains the role,
|
|
@@ -92,13 +116,13 @@ export function buildFirstStagePrompt({ userInput }) {
|
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
// Per-turn prompt — Stop hook stage.
|
|
95
|
-
|
|
119
|
+
// v0.13.0: user_input は渡さない。判定軸が「ユーザー要請の充足」から「final_response に
|
|
120
|
+
// ツール適用機会があるか」に変わったため。挨拶ターンの早期 pass は daemon 側の
|
|
121
|
+
// state.lastUserInput === null 分岐 (handleTurnEnd) で処理する。
|
|
122
|
+
export function buildFinalStagePrompt({ usedTools, finalResponse }) {
|
|
96
123
|
const usedList = usedTools.length > 0 ? usedTools.map((t) => `- ${t}`).join('\n') : '(なし)';
|
|
97
124
|
return [
|
|
98
125
|
'stage=turn_end',
|
|
99
|
-
'<user_input>',
|
|
100
|
-
userInput,
|
|
101
|
-
'</user_input>',
|
|
102
126
|
'<used_tools>',
|
|
103
127
|
usedList,
|
|
104
128
|
'</used_tools>',
|
|
@@ -11,31 +11,21 @@
|
|
|
11
11
|
// v0.3 gate:
|
|
12
12
|
// - isOutsideSpotterProject: cwd has no .spotter/marker.json above it → exit 0
|
|
13
13
|
// (Throughline workdir etc. — `claude -p` from tools outside any installed project)
|
|
14
|
+
//
|
|
15
|
+
// v0.12.0: spawn + readiness-poll moved to spawn-daemon.mjs (shared with auto-resurrect
|
|
16
|
+
// in user-prompt.mjs). The --parent-pid scheme from v0.6.2 is gone; orphan cleanup is
|
|
17
|
+
// now heartbeat-based inside the daemon.
|
|
14
18
|
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { dirname, resolve } from 'node:path';
|
|
19
19
|
import { readStdinJson, requireString, die, isChildCall, isSubagentCall, isOutsideSpotterProject, findSpotterMarker } from './lib.mjs';
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
const READINESS_TIMEOUT_MS = 3_000;
|
|
23
|
-
const POLL_INTERVAL_MS = 100;
|
|
20
|
+
import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
|
|
24
21
|
|
|
25
|
-
export async function runSessionStart({
|
|
26
|
-
// Gate 1 (pre-stdin): Spotter's own claude -p subprocess — exit without reading stdin.
|
|
22
|
+
export async function runSessionStart({ now = Date.now } = {}) {
|
|
27
23
|
if (isChildCall()) return;
|
|
28
24
|
|
|
29
25
|
const input = await readStdinJson();
|
|
30
26
|
|
|
31
|
-
// Gate 2: Task subagent — skip audit.
|
|
32
27
|
if (isSubagentCall(input)) return;
|
|
33
|
-
|
|
34
|
-
// Gate 3: non-startup sources (resume/compact/clear) don't spawn a new daemon.
|
|
35
28
|
if (input.source !== 'startup') return;
|
|
36
|
-
|
|
37
|
-
// Gate 4 (v0.3): cwd is not inside any project that has been `spotter install`-ed.
|
|
38
|
-
// Other tools (Throughline etc.) launching `claude -p` in unrelated workdirs land here.
|
|
39
29
|
if (isOutsideSpotterProject(input)) return;
|
|
40
30
|
|
|
41
31
|
const sessionId = requireString(input, 'session_id');
|
|
@@ -44,66 +34,9 @@ export async function runSessionStart({ argv = process.argv, now = Date.now } =
|
|
|
44
34
|
die(`SessionStart: failed to locate project root from cwd=${input.cwd}`, 2);
|
|
45
35
|
}
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const deadline = now() + READINESS_TIMEOUT_MS;
|
|
50
|
-
while (now() < deadline) {
|
|
51
|
-
try {
|
|
52
|
-
const resp = await sendRequest({
|
|
53
|
-
sessionId,
|
|
54
|
-
event: 'readiness',
|
|
55
|
-
timeoutMs: 500,
|
|
56
|
-
});
|
|
57
|
-
if (resp.ok === true && resp.result && resp.result.ready === true) {
|
|
58
|
-
return; // success — exit 0 implicitly
|
|
59
|
-
}
|
|
60
|
-
} catch (err) {
|
|
61
|
-
if (!(err instanceof TransportError) || err.code !== 'E_UNREACHABLE') {
|
|
62
|
-
// E_TIMEOUT or internal errors while daemon is booting — keep polling.
|
|
63
|
-
// E_UNREACHABLE means the socket file/pipe doesn't exist yet — also retryable.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
await delay(POLL_INTERVAL_MS);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
die(`daemon did not reach readiness within ${READINESS_TIMEOUT_MS}ms for session ${sessionId}`, 2);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function spawnDaemon(sessionId, projectRoot, argv) {
|
|
73
|
-
// Invoke `node <spotter-bin> daemon start --session-id ...` detached.
|
|
74
|
-
// v0.6.2: pass --parent-pid so the daemon can self-terminate when Claude Code dies
|
|
75
|
-
// without firing SessionEnd. process.ppid here is Claude Code (this hook's parent).
|
|
76
|
-
// v0.7.0: pass --project-root for tool-db loading.
|
|
77
|
-
const spotterBin = resolveSpotterBin(argv);
|
|
78
|
-
const child = spawn(
|
|
79
|
-
process.execPath,
|
|
80
|
-
[
|
|
81
|
-
spotterBin, 'daemon', 'start',
|
|
82
|
-
'--session-id', sessionId,
|
|
83
|
-
'--parent-pid', String(process.ppid),
|
|
84
|
-
'--project-root', projectRoot,
|
|
85
|
-
],
|
|
86
|
-
{
|
|
87
|
-
detached: true,
|
|
88
|
-
stdio: 'ignore',
|
|
89
|
-
windowsHide: true,
|
|
90
|
-
}
|
|
91
|
-
);
|
|
92
|
-
child.on('error', (err) => {
|
|
93
|
-
// best effort: the polling below will fail if the spawn actually didn't work
|
|
94
|
-
process.stderr.write(`spotter-hook: daemon spawn error: ${err.message}\n`);
|
|
95
|
-
});
|
|
96
|
-
child.unref();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function resolveSpotterBin(argv) {
|
|
100
|
-
// argv[1] is the path to the currently running script (bin/spotter.mjs when invoked via CLI,
|
|
101
|
-
// or src/hooks/session-start.mjs in tests). Walk up to the package root.
|
|
102
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
103
|
-
return resolve(here, '..', '..', 'bin', 'spotter.mjs');
|
|
37
|
+
await spawnDaemonAndWaitReady({ sessionId, projectRoot, now });
|
|
104
38
|
}
|
|
105
39
|
|
|
106
|
-
// Direct-execution entry — used when called as `node session-start.mjs`.
|
|
107
40
|
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
108
41
|
runSessionStart().catch((err) => die(err.message, err.exitCode ?? 2));
|
|
109
42
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Shared daemon-spawn + readiness-poll plumbing.
|
|
2
|
+
//
|
|
3
|
+
// Used by:
|
|
4
|
+
// - session-start.mjs (initial daemon for a fresh session)
|
|
5
|
+
// - user-prompt.mjs (auto-resurrect when a heartbeat-killed or crashed daemon
|
|
6
|
+
// is detected at the start of a new turn — v0.12.0)
|
|
7
|
+
//
|
|
8
|
+
// The hook caller is responsible for the v0.2 gates (isChildCall / isSubagentCall /
|
|
9
|
+
// isOutsideSpotterProject) and for resolving sessionId + projectRoot before invoking
|
|
10
|
+
// this. We keep this module narrowly focused on "spawn detached + poll until ready".
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, resolve } from 'node:path';
|
|
16
|
+
import { sendRequest, TransportError } from '../daemon/transport.mjs';
|
|
17
|
+
|
|
18
|
+
const READINESS_TIMEOUT_MS = 3_000;
|
|
19
|
+
const POLL_INTERVAL_MS = 100;
|
|
20
|
+
|
|
21
|
+
export async function spawnDaemonAndWaitReady({ sessionId, projectRoot, now = Date.now }) {
|
|
22
|
+
spawnDaemon(sessionId, projectRoot);
|
|
23
|
+
|
|
24
|
+
const deadline = now() + READINESS_TIMEOUT_MS;
|
|
25
|
+
while (now() < deadline) {
|
|
26
|
+
try {
|
|
27
|
+
const resp = await sendRequest({
|
|
28
|
+
sessionId,
|
|
29
|
+
event: 'readiness',
|
|
30
|
+
timeoutMs: 500,
|
|
31
|
+
});
|
|
32
|
+
if (resp.ok === true && resp.result && resp.result.ready === true) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (!(err instanceof TransportError) || err.code !== 'E_UNREACHABLE') {
|
|
37
|
+
// E_TIMEOUT or internal errors while daemon is booting — keep polling.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await delay(POLL_INTERVAL_MS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const err = new Error(`daemon did not reach readiness within ${READINESS_TIMEOUT_MS}ms for session ${sessionId}`);
|
|
44
|
+
err.exitCode = 2;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function spawnDaemon(sessionId, projectRoot) {
|
|
49
|
+
const spotterBin = resolveSpotterBin();
|
|
50
|
+
const child = spawn(
|
|
51
|
+
process.execPath,
|
|
52
|
+
[
|
|
53
|
+
spotterBin, 'daemon', 'start',
|
|
54
|
+
'--session-id', sessionId,
|
|
55
|
+
'--project-root', projectRoot,
|
|
56
|
+
],
|
|
57
|
+
{
|
|
58
|
+
detached: true,
|
|
59
|
+
stdio: 'ignore',
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
child.on('error', (err) => {
|
|
64
|
+
process.stderr.write(`spotter-hook: daemon spawn error: ${err.message}\n`);
|
|
65
|
+
});
|
|
66
|
+
child.unref();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveSpotterBin() {
|
|
70
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
71
|
+
return resolve(here, '..', '..', 'bin', 'spotter.mjs');
|
|
72
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// UserPromptSubmit hook — send user_input to daemon, inject additionalContext (§12.2 transparent).
|
|
2
2
|
// v0.2 gates: see src/hooks/session-start.mjs comment.
|
|
3
|
+
//
|
|
4
|
+
// v0.12.0: auto-resurrect. If sendRequest fails with E_UNREACHABLE (daemon was
|
|
5
|
+
// heartbeat-killed for 30 min idle, crashed, or was never spawned because SessionStart
|
|
6
|
+
// fired in a context we skipped), we spawn a fresh daemon and retry once. This is the
|
|
7
|
+
// natural recovery point — the start of a new turn — so the user's prompt is still
|
|
8
|
+
// audited even after long pauses or daemon failures.
|
|
3
9
|
|
|
4
10
|
import {
|
|
5
11
|
readStdinJson,
|
|
@@ -10,8 +16,10 @@ import {
|
|
|
10
16
|
isChildCall,
|
|
11
17
|
isSubagentCall,
|
|
12
18
|
isOutsideSpotterProject,
|
|
19
|
+
findSpotterMarker,
|
|
13
20
|
} from './lib.mjs';
|
|
14
|
-
import { sendRequest } from '../daemon/transport.mjs';
|
|
21
|
+
import { sendRequest, TransportError } from '../daemon/transport.mjs';
|
|
22
|
+
import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
|
|
15
23
|
|
|
16
24
|
const TIMEOUT_MS = 30_000;
|
|
17
25
|
const SHORT_PROMPT_MAX_CHARS = 10;
|
|
@@ -25,22 +33,43 @@ export async function runUserPrompt() {
|
|
|
25
33
|
const sessionId = requireString(input, 'session_id');
|
|
26
34
|
const prompt = requireString(input, 'prompt');
|
|
27
35
|
|
|
28
|
-
// Short prompts (<=10 codepoints after trim) are almost never tool-required
|
|
29
|
-
// (greetings, acknowledgements, short questions). Skip Haiku entirely —
|
|
30
|
-
// daemon keeps lastUserInput=null, so the next turn_end passes with reason=no_user_input.
|
|
31
36
|
if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) return;
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
response = await sendRequest({
|
|
38
|
+
const sendUserInput = () =>
|
|
39
|
+
sendRequest({
|
|
36
40
|
sessionId,
|
|
37
41
|
event: 'user_input',
|
|
38
42
|
payload: { user_input: prompt },
|
|
39
43
|
timeoutMs: TIMEOUT_MS,
|
|
40
44
|
});
|
|
45
|
+
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await sendUserInput();
|
|
41
49
|
} catch (err) {
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
if (err instanceof TransportError && err.code === 'E_UNREACHABLE') {
|
|
51
|
+
// v0.12.0: daemon is gone (heartbeat shutdown, crash, missing). Resurrect and retry.
|
|
52
|
+
const projectRoot = findSpotterMarker(input.cwd);
|
|
53
|
+
if (!projectRoot) {
|
|
54
|
+
die(`user-prompt: cannot resurrect daemon — no .spotter/marker.json above cwd=${input.cwd}`, 2);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await spawnDaemonAndWaitReady({ sessionId, projectRoot });
|
|
59
|
+
} catch (spawnErr) {
|
|
60
|
+
die(`user-prompt: daemon resurrect failed: ${spawnErr.message}`, spawnErr.exitCode ?? 2);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
response = await sendUserInput();
|
|
65
|
+
} catch (retryErr) {
|
|
66
|
+
die(`user-prompt transport failure after resurrect: ${retryErr.code ?? '?'}: ${retryErr.message}`, exitCodeFor(retryErr));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
die(`user-prompt transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
if (response.ok !== true) {
|
|
@@ -50,7 +79,7 @@ export async function runUserPrompt() {
|
|
|
50
79
|
|
|
51
80
|
const result = response.result;
|
|
52
81
|
if (result.pass === true) {
|
|
53
|
-
return;
|
|
82
|
+
return;
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
const additionalContext = formatTransparentContext(result.missing_tools);
|