claude-spotter 0.2.0 → 0.3.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 +38 -0
- package/bin/spotter.mjs +9 -2
- package/package.json +1 -1
- package/scripts/postinstall.mjs +21 -34
- package/scripts/preuninstall.mjs +10 -4
- package/src/cli/daemon-cmd.mjs +1 -1
- package/src/cli/install.mjs +29 -1
- package/src/cli/uninstall.mjs +22 -1
- package/src/daemon/daemon.mjs +28 -0
- package/src/daemon/haiku-caller.mjs +24 -0
- package/src/hooks/lib.mjs +41 -0
- package/src/hooks/pre-tool-use.mjs +2 -1
- package/src/hooks/session-end.mjs +2 -1
- package/src/hooks/session-start.mjs +9 -1
- package/src/hooks/stop.mjs +2 -0
- package/src/hooks/user-prompt.mjs +2 -0
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
v0.2.1 で追跡課題として残していた **daemon 増殖問題の根本原因を特定** (実セッション 64 分の生ログ調査)。74 個生成された daemon のうち 51 個が Throughline (token-monitor) の `claude -p` 由来で、残り 23 個も同種の他ツール起動と推定された。
|
|
6
|
+
|
|
7
|
+
5 層防御は **Spotter 自身の `claude -p` 再帰** と **Bell の Task subagent** はカバーするが、**他ツールが起動する `claude -p` 経由の SessionStart** には無防備だった。原因は v0.1.1 で導入した `npm postinstall` の `~/.claude/settings.json` (user-global) への自動 hook 登録 — システム全体のあらゆる Claude Code セッションが Spotter hook を読み込む構造になっていた。
|
|
8
|
+
|
|
9
|
+
### 変更点
|
|
10
|
+
|
|
11
|
+
- **`postinstall` の自動登録を撤回**: `npm install -g claude-spotter` は CLI を使える状態にするだけ。`~/.claude/settings.json` への書き込みは行わない (案内文を出すのみ)。
|
|
12
|
+
- **`spotter install` が project-scoped に**: `<cwd>/.claude/settings.json` に hook を書き、同時に `<cwd>/.spotter/marker.json` を作成する。`--user` フラグで旧来の user-global 登録も可能だが非推奨。
|
|
13
|
+
- **`spotter uninstall` も project-scoped がデフォルト**: project mode 時に `<cwd>/.spotter/marker.json` も削除する (`.spotter/` ディレクトリ自体は残す)。
|
|
14
|
+
- **新ガード `isOutsideSpotterProject(input)`**: 5 つの hook の冒頭で hook input の `cwd` を起点に上向きに `.spotter/marker.json` を探し、見つからなければ `exit 0`。Throughline 等の他ツールが別 workdir で `claude -p` を呼んだ場合、そもそも Spotter hook 自体が無視される (実測の Throughline 由来 51 件のうち 49 件は別 workdir 起動なので、このガード単独で 96% を hook 側で完全遮断)。
|
|
15
|
+
- **`preuninstall` を縮小**: legacy user-scope hook の cleanup は best-effort で残し、project-level hook は各プロジェクトでユーザーが明示 uninstall するよう案内する。
|
|
16
|
+
|
|
17
|
+
### Breaking
|
|
18
|
+
|
|
19
|
+
- `npm install -g claude-spotter` 後に各プロジェクトで `spotter install` を一度実行する必要がある (v0.1.1 / v0.2.x の自動登録は撤回された)。
|
|
20
|
+
- 旧バージョンの user-global hook 登録は `npm uninstall` 時に preuninstall が cleanup を試みるが、各プロジェクトの hook 登録は手動 uninstall が必要。
|
|
21
|
+
|
|
22
|
+
### 持ち越し
|
|
23
|
+
|
|
24
|
+
- A-2 warmup の `--resume` 40+秒 timeout 問題 (v0.2.1 の追跡課題) は本リリースでは未対応 — 別枠で調査継続。
|
|
25
|
+
|
|
26
|
+
## 0.2.1
|
|
27
|
+
|
|
28
|
+
v0.2.0 の実セッション観測で `UserPromptSubmit` 経路に `E_HAIKU_TIMEOUT` が集中していることが判明 (20 分で 14 件、全て `handler error on user_input`)。Stop hook 側はタイムアウトゼロ。原因は初回 Haiku spawn (Windows: `cmd.exe /c claude.cmd -p --session-id ...`) のコールドスタートが 28s 超になるケースで、これが UserPromptSubmit hook のブロック中に直撃していた。
|
|
29
|
+
|
|
30
|
+
- **Haiku 非同期ウォームアップ (A-2)**: `startDaemon({ warmup: true })` オプションを追加。`daemon-cmd.mjs` (SessionStart 経由のエントリ) で `true` を渡す。daemon は `server.listen` 完了直後に fire-and-forget で `buildWarmupPrompt` を Haiku に送信し、`--session-id` での新規会話作成とカタログ読み込みを前倒しする。SessionStart hook の readiness ping は `daemon listening` 確認のみで完了するためユーザー体感の起動遅延ゼロ。
|
|
31
|
+
- **初回 `user_input` は `--resume` 経由**: ウォームアップ完了後、既存の `haikuChain` mutex が最初の real call に warmup の完了を待たせ、`isFirst=false` で `claude -p --resume` が走る。
|
|
32
|
+
- **warmup 後の 10 秒ウィンドウリセット**: ウォームアップも `claude -p` spawn なので `lastHaikuCallAt` を更新するが、完了時 (成否問わず) に 0 にリセットして layer 5 が warmup 直後の合法的 `user_input` を silent pass にしないようにする。SPOTTER_PARENT_PID env 他のレイヤーで recursion は遮断済みなのでリセットは安全。
|
|
33
|
+
- **`buildWarmupPrompt` 新設**: 既存の `buildFirstStagePrompt` を流用せず、Haiku に trivial pass (`{"pass":true,"missing_tools":[]}`) を返させる固定プロンプトを採用。`parseHaikuResponse` のスキーマチェックを通過する形で warmup が成功し、`haikuInitialized=true` が立つ。
|
|
34
|
+
- **失敗時は従来動作**: warmup が失敗すると `haikuInitialized=false` のまま残り、次の real call が `--session-id` で仕切り直す。悪化なし。
|
|
35
|
+
|
|
36
|
+
### 観測対象として残した課題 (v0.2.1 では未対応)
|
|
37
|
+
|
|
38
|
+
- **20 分で 28 daemon 生成**: 実セッション観測で §18.4 の 5 層防御がすり抜けている疑い (状況的には別 VSCode の旧 daemon 残存も仮説)。A-2 とは独立の bug 調査として次タスク化。
|
|
39
|
+
- **カタログのツール名抽象**: 実ツール名 (`current_time` カタログ記載 vs 実環境 `Bash:date`) のマッピング論点、v0.3 持ち越し。
|
|
40
|
+
|
|
3
41
|
## 0.2.0
|
|
4
42
|
|
|
5
43
|
Fixes the v0.1.x daemon proliferation by adding multiple defence layers that together prevent any
|
package/bin/spotter.mjs
CHANGED
|
@@ -17,8 +17,15 @@ import { runSessionEnd } from '../src/hooks/session-end.mjs';
|
|
|
17
17
|
const USAGE = `spotter — Claude Code tool-call auditor
|
|
18
18
|
|
|
19
19
|
Usage:
|
|
20
|
-
spotter install [
|
|
21
|
-
|
|
20
|
+
spotter install [-y] register hooks in <cwd>/.claude/settings.json
|
|
21
|
+
and create <cwd>/.spotter/marker.json
|
|
22
|
+
(run inside each project you want audited)
|
|
23
|
+
spotter install --user [-y] legacy: register globally in ~/.claude/settings.json
|
|
24
|
+
(NOT RECOMMENDED — fires for every Claude Code session
|
|
25
|
+
on the system, including unrelated \`claude -p\`)
|
|
26
|
+
spotter uninstall [-y] remove spotter hooks from <cwd>/.claude/settings.json
|
|
27
|
+
and remove <cwd>/.spotter/marker.json
|
|
28
|
+
spotter uninstall --user [-y] remove from ~/.claude/settings.json
|
|
22
29
|
spotter catalog edit open tool catalog in $EDITOR
|
|
23
30
|
spotter catalog lint validate catalog + run test_cases (Haiku live call)
|
|
24
31
|
spotter status show running daemons
|
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,42 +1,29 @@
|
|
|
1
1
|
// Runs after `npm install (-g) claude-spotter`.
|
|
2
|
-
// Registers hooks at user level so Spotter is active across all projects
|
|
3
|
-
// without requiring a separate `spotter install` step.
|
|
4
2
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
// v0.3: auto-register has been removed. The previous behaviour (writing hooks to
|
|
4
|
+
// ~/.claude/settings.json globally) caused daemon proliferation: every Claude Code
|
|
5
|
+
// session anywhere on the system — including `claude -p` invocations from unrelated
|
|
6
|
+
// tools like Throughline — fired the hooks and spawned a daemon. The fix is
|
|
7
|
+
// project-scoped install: the user runs `spotter install` inside each project they
|
|
8
|
+
// want audited, which writes hooks to that project's .claude/settings.json plus a
|
|
9
|
+
// .spotter/marker.json that hooks check before doing any work.
|
|
10
|
+
//
|
|
11
|
+
// This script now only prints onboarding guidance.
|
|
10
12
|
|
|
11
13
|
const SKIP_ENV = 'CLAUDE_SPOTTER_NO_AUTO_INSTALL';
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Skip in well-known CI environments — CI builds shouldn't silently modify
|
|
21
|
-
// ~/.claude/settings.json of whatever runner user this is.
|
|
22
|
-
if (process.env.CI === 'true' || process.env.CI === '1') {
|
|
23
|
-
console.log('claude-spotter: auto-install skipped (CI detected).');
|
|
24
|
-
console.log(' run `spotter install --user` on your dev machine.');
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
15
|
+
// Honor the legacy skip env var for parity with old guidance, but it's a no-op now.
|
|
16
|
+
if (process.env[SKIP_ENV]) {
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
27
19
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
console.log('\nclaude-spotter: hooks registered at user level (~/.claude/settings.json).');
|
|
31
|
-
console.log(' Open a new Claude Code session to activate.');
|
|
32
|
-
console.log(` To skip this next time: set ${SKIP_ENV}=1 before npm install.`);
|
|
33
|
-
} catch (err) {
|
|
34
|
-
console.warn(`claude-spotter: auto-install skipped — ${err.message}`);
|
|
35
|
-
console.warn(' run `spotter install --user` to register hooks manually.');
|
|
36
|
-
}
|
|
20
|
+
if (process.env.CI === 'true' || process.env.CI === '1') {
|
|
21
|
+
process.exit(0);
|
|
37
22
|
}
|
|
38
23
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
24
|
+
console.log('claude-spotter installed.');
|
|
25
|
+
console.log(' Next step (per project you want audited):');
|
|
26
|
+
console.log(' cd <your-project>');
|
|
27
|
+
console.log(' spotter install');
|
|
28
|
+
console.log(' This writes hooks to <project>/.claude/settings.json and a .spotter/');
|
|
29
|
+
console.log(' marker so unrelated `claude -p` invocations do not trigger Spotter.');
|
package/scripts/preuninstall.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Runs before `npm uninstall (-g) claude-spotter`.
|
|
2
|
-
//
|
|
2
|
+
//
|
|
3
|
+
// v0.3: with project-scoped install (no global hook registration), preuninstall
|
|
4
|
+
// has nothing global to clean. We do a best-effort cleanup of the legacy
|
|
5
|
+
// ~/.claude/settings.json registration in case the user upgraded from <0.3, then
|
|
6
|
+
// print guidance for project-level uninstall.
|
|
3
7
|
//
|
|
4
8
|
// Never fails the uninstall — on error we warn and exit 0.
|
|
5
9
|
|
|
@@ -7,8 +11,10 @@ import { runUninstall } from '../src/cli/uninstall.mjs';
|
|
|
7
11
|
|
|
8
12
|
try {
|
|
9
13
|
await runUninstall({ target: 'user', autoYes: true });
|
|
10
|
-
console.log('claude-spotter: hooks removed from ~/.claude/settings.json.');
|
|
11
14
|
} catch (err) {
|
|
12
|
-
console.warn(`claude-spotter:
|
|
13
|
-
console.warn(' you may need to edit ~/.claude/settings.json manually.');
|
|
15
|
+
console.warn(`claude-spotter: legacy user-scope cleanup skipped — ${err.message}`);
|
|
14
16
|
}
|
|
17
|
+
|
|
18
|
+
console.log('claude-spotter: per-project hooks (in <project>/.claude/settings.json) are not removed automatically.');
|
|
19
|
+
console.log(' To remove them, run `spotter uninstall` in each project before this uninstall completes,');
|
|
20
|
+
console.log(' or edit <project>/.claude/settings.json manually after.');
|
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export async function runDaemonStart({ argv }) {
|
|
|
34
34
|
|
|
35
35
|
let running;
|
|
36
36
|
try {
|
|
37
|
-
running = await startDaemon({ sessionId, logFn: log });
|
|
37
|
+
running = await startDaemon({ sessionId, logFn: log, warmup: true });
|
|
38
38
|
} catch (err) {
|
|
39
39
|
if (err instanceof DaemonAlreadyRunningError) {
|
|
40
40
|
// v0.2 PID-preexist layer: a sibling daemon already serves this session.
|
package/src/cli/install.mjs
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// `spotter install` — create ~/.spotter/, place template catalog, 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
|
+
//
|
|
5
|
+
// v0.3: also writes <cwd>/.spotter/marker.json (project mode) so hooks can detect
|
|
6
|
+
// "this Claude Code session is rooted in a project where Spotter is installed" and
|
|
7
|
+
// silently exit otherwise (prevents Throughline-style proliferation).
|
|
4
8
|
|
|
5
9
|
import { mkdir, writeFile, readFile, access, copyFile } from 'node:fs/promises';
|
|
6
10
|
import { homedir } from 'node:os';
|
|
7
11
|
import { join, resolve, dirname } from 'node:path';
|
|
8
12
|
import { fileURLToPath } from 'node:url';
|
|
9
13
|
import { createInterface } from 'node:readline/promises';
|
|
14
|
+
import { version as SPOTTER_VERSION } from '../version.mjs';
|
|
10
15
|
|
|
11
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
12
17
|
const PACKAGE_ROOT = resolve(HERE, '..', '..');
|
|
@@ -16,6 +21,8 @@ const SPOTTER_BIN = join(PACKAGE_ROOT, 'bin', 'spotter.mjs');
|
|
|
16
21
|
const SPOTTER_HOME = join(homedir(), '.spotter');
|
|
17
22
|
const CATALOG_DEST = join(SPOTTER_HOME, 'tool-catalog', 'tools.yaml');
|
|
18
23
|
|
|
24
|
+
const MARKER_VERSION = '1';
|
|
25
|
+
|
|
19
26
|
const HOOK_EVENTS = [
|
|
20
27
|
{ event: 'SessionStart', sub: 'session-start', timeout: 5 },
|
|
21
28
|
{ event: 'UserPromptSubmit', sub: 'user-prompt', timeout: 30 },
|
|
@@ -48,7 +55,28 @@ export async function runInstall({ target = 'project', autoYes = false, cwd = pr
|
|
|
48
55
|
console.log(` catalog already present at ${CATALOG_DEST} (not overwritten)`);
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
// 3.
|
|
58
|
+
// 3. project marker (v0.3): hooks use this to detect installed projects.
|
|
59
|
+
// Skipped in user-mode install — user-mode is a deprecated escape hatch and
|
|
60
|
+
// intentionally has no marker, so all hooks would exit. (Existing user-mode
|
|
61
|
+
// installs from <0.3 won't surprise-stop working only because of this — they
|
|
62
|
+
// were already broken by daemon proliferation.)
|
|
63
|
+
//
|
|
64
|
+
// Always overwritten so that `spotter install` after a version bump refreshes
|
|
65
|
+
// `spotterVersion` / `installedAt` rather than leaving stale metadata.
|
|
66
|
+
if (target === 'project') {
|
|
67
|
+
const markerDir = join(cwd, '.spotter');
|
|
68
|
+
const markerPath = join(markerDir, 'marker.json');
|
|
69
|
+
await mkdir(markerDir, { recursive: true });
|
|
70
|
+
const marker = {
|
|
71
|
+
markerVersion: MARKER_VERSION,
|
|
72
|
+
spotterVersion: SPOTTER_VERSION,
|
|
73
|
+
installedAt: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
await writeFile(markerPath, JSON.stringify(marker, null, 2) + '\n', 'utf8');
|
|
76
|
+
console.log(` wrote ${markerPath}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 4. compute desired settings.json with hooks
|
|
52
80
|
const current = await loadSettings(settingsPath);
|
|
53
81
|
const updated = mergeHooks(current);
|
|
54
82
|
const diff = diffSettings(current, updated);
|
package/src/cli/uninstall.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// `spotter uninstall` — remove hook entries that reference this spotter installation.
|
|
2
2
|
// Does NOT delete ~/.spotter/ (user data), just unregisters hooks.
|
|
3
|
+
//
|
|
4
|
+
// v0.3: in project mode also removes the .spotter/marker.json (so that hooks would
|
|
5
|
+
// silently exit even if the settings.json removal somehow failed). The .spotter/
|
|
6
|
+
// directory itself is left alone in case the user has additional content there.
|
|
3
7
|
|
|
4
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { readFile, writeFile, rm, stat } from 'node:fs/promises';
|
|
5
9
|
import { homedir } from 'node:os';
|
|
6
10
|
import { join } from 'node:path';
|
|
7
11
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -13,6 +17,10 @@ export async function runUninstall({ target = 'project', autoYes = false, cwd =
|
|
|
13
17
|
|
|
14
18
|
console.log(`spotter uninstall (settings: ${settingsPath})`);
|
|
15
19
|
|
|
20
|
+
if (target === 'project') {
|
|
21
|
+
await removeMarker(cwd);
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
let current;
|
|
17
25
|
try {
|
|
18
26
|
current = JSON.parse(await readFile(settingsPath, 'utf8'));
|
|
@@ -73,3 +81,16 @@ export async function runUninstall({ target = 'project', autoYes = false, cwd =
|
|
|
73
81
|
console.log(`wrote ${settingsPath}`);
|
|
74
82
|
console.log('note: ~/.spotter/ (catalog, logs) was not removed. delete manually if no longer needed.');
|
|
75
83
|
}
|
|
84
|
+
|
|
85
|
+
async function removeMarker(cwd) {
|
|
86
|
+
const markerPath = join(cwd, '.spotter', 'marker.json');
|
|
87
|
+
try {
|
|
88
|
+
await stat(markerPath);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code === 'ENOENT') return;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
await rm(markerPath, { force: true });
|
|
94
|
+
console.log(` removed ${markerPath}`);
|
|
95
|
+
// Leave the .spotter/ directory itself in place — the user may keep other files there.
|
|
96
|
+
}
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { createServer, ensureRuntimeDir, socketPath } from './transport.mjs';
|
|
|
21
21
|
import {
|
|
22
22
|
buildFirstStagePrompt,
|
|
23
23
|
buildFinalStagePrompt,
|
|
24
|
+
buildWarmupPrompt,
|
|
24
25
|
parseHaikuResponse,
|
|
25
26
|
createHaikuCaller,
|
|
26
27
|
} from './haiku-caller.mjs';
|
|
@@ -48,6 +49,7 @@ export async function startDaemon({
|
|
|
48
49
|
haikuCaller,
|
|
49
50
|
haikuSessionId,
|
|
50
51
|
logFn = () => {},
|
|
52
|
+
warmup = false,
|
|
51
53
|
} = {}) {
|
|
52
54
|
if (!sessionId) {
|
|
53
55
|
throw new TypeError('sessionId is required');
|
|
@@ -229,11 +231,37 @@ export async function startDaemon({
|
|
|
229
231
|
const pidPath = pidFilePath(sessionId);
|
|
230
232
|
await writeFile(pidPath, String(process.pid), 'utf8');
|
|
231
233
|
|
|
234
|
+
// A-2: fire-and-forget Haiku warmup. Pays the cold-start cost during SessionStart
|
|
235
|
+
// (while the user is still composing their first prompt) rather than blocking the
|
|
236
|
+
// first UserPromptSubmit. On success the Haiku conversation is ready for --resume
|
|
237
|
+
// and subsequent calls respond within the 28s timeout. On failure we log and leave
|
|
238
|
+
// haikuInitialized=false so the next real call retries as --session-id (no regression).
|
|
239
|
+
// haikuChain serialises this against any incoming event, preventing double-init.
|
|
240
|
+
//
|
|
241
|
+
// After warmup settles (success or failure) we reset lastHaikuCallAt so that the first
|
|
242
|
+
// real user_input is not spuriously silenced by the 10-second recursion window. The
|
|
243
|
+
// SPOTTER_PARENT_PID env var and agent_id gate already prevent genuine recursion from
|
|
244
|
+
// the warmup spawn, so this reset does not regress the defence.
|
|
245
|
+
let warmupPromise = null;
|
|
246
|
+
if (warmup) {
|
|
247
|
+
warmupPromise = callHaikuTracked(() => buildWarmupPrompt({ catalog })).then(
|
|
248
|
+
() => {
|
|
249
|
+
logFn('warmup: haiku session initialised');
|
|
250
|
+
lastHaikuCallAt = 0;
|
|
251
|
+
},
|
|
252
|
+
(err) => {
|
|
253
|
+
logFn(`warmup failed: ${err.code ?? 'E_INTERNAL'}: ${err.message}`);
|
|
254
|
+
lastHaikuCallAt = 0;
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
232
259
|
return {
|
|
233
260
|
server,
|
|
234
261
|
path,
|
|
235
262
|
pidPath,
|
|
236
263
|
haikuSessionId: ownHaikuSessionId,
|
|
264
|
+
warmupPromise,
|
|
237
265
|
stop: () => shutdown(server, sessionId, logFn),
|
|
238
266
|
};
|
|
239
267
|
}
|
|
@@ -101,6 +101,30 @@ export function buildFinalStagePrompt({ catalog, userInput, usedTools, finalResp
|
|
|
101
101
|
].join('\n');
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// Build a warmup prompt — fired by the daemon right after `server.listen` to pay the
|
|
105
|
+
// Haiku cold-start cost before the first user_input arrives. Uses --session-id to create
|
|
106
|
+
// the Haiku conversation with catalog + rules loaded; subsequent real calls hit --resume
|
|
107
|
+
// and respond within the hook timeout.
|
|
108
|
+
// The returned response is discarded by the caller; we instruct Haiku to return the trivial
|
|
109
|
+
// pass object so that parseHaikuResponse does not throw on the warmup result.
|
|
110
|
+
export function buildWarmupPrompt({ catalog }) {
|
|
111
|
+
const toolsProjection = catalog.tools.map((t) => ({
|
|
112
|
+
name: t.name,
|
|
113
|
+
purpose: t.purpose,
|
|
114
|
+
when_to_use: t.when_to_use,
|
|
115
|
+
}));
|
|
116
|
+
return [
|
|
117
|
+
systemRules(),
|
|
118
|
+
'## ツールカタログ',
|
|
119
|
+
JSON.stringify(toolsProjection, null, 2),
|
|
120
|
+
'',
|
|
121
|
+
'## ウォームアップ呼び出し',
|
|
122
|
+
'これはセッション開始直後のウォームアップ呼び出しです。実際のユーザー入力はまだありません。',
|
|
123
|
+
'以降の判定に備えて、上記カタログと判定ルールをコンテキストに保持してください。',
|
|
124
|
+
'この呼び出しでは必ず `{"pass": true, "missing_tools": []}` のみを返してください。',
|
|
125
|
+
].join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
104
128
|
function systemRules() {
|
|
105
129
|
return [
|
|
106
130
|
'あなたは Spotter — Claude (Bell) が呼び忘れているツールを検出する監査役です。',
|
package/src/hooks/lib.mjs
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
// - isSubagentCall(input): agent_id gate for Bell's Task subagent hooks
|
|
13
13
|
// Combined with session-start's source='startup' check, these prevent daemon
|
|
14
14
|
// proliferation (v0.1 postmortem §18.2).
|
|
15
|
+
//
|
|
16
|
+
// v0.3 gate (plan §18 daemon-proliferation root fix):
|
|
17
|
+
// - findSpotterMarker(cwd): walk up from cwd looking for .spotter/marker.json.
|
|
18
|
+
// Hooks exit 0 when no marker is found, so other tools' `claude -p` invocations
|
|
19
|
+
// in unrelated workdirs (Throughline workdir etc.) never spawn a daemon.
|
|
20
|
+
|
|
21
|
+
import { statSync } from 'node:fs';
|
|
22
|
+
import { dirname, join, parse } from 'node:path';
|
|
15
23
|
|
|
16
24
|
export function isChildCall() {
|
|
17
25
|
const v = process.env.SPOTTER_PARENT_PID;
|
|
@@ -25,6 +33,39 @@ export function isSubagentCall(input) {
|
|
|
25
33
|
&& input.agent_id.length > 0;
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
// Walk up from startCwd looking for .spotter/marker.json. Returns the project
|
|
37
|
+
// root path containing the marker, or null if none was found before reaching
|
|
38
|
+
// the filesystem root.
|
|
39
|
+
//
|
|
40
|
+
// Synchronous fs is intentional — hooks run on every Claude Code event and
|
|
41
|
+
// must add minimal latency. statSync of one file per directory level is cheap.
|
|
42
|
+
export function findSpotterMarker(startCwd) {
|
|
43
|
+
if (typeof startCwd !== 'string' || startCwd.length === 0) return null;
|
|
44
|
+
let dir = startCwd;
|
|
45
|
+
const root = parse(dir).root;
|
|
46
|
+
while (true) {
|
|
47
|
+
const marker = join(dir, '.spotter', 'marker.json');
|
|
48
|
+
try {
|
|
49
|
+
const st = statSync(marker);
|
|
50
|
+
if (st.isFile()) return dir;
|
|
51
|
+
} catch {
|
|
52
|
+
// marker missing at this level — keep walking up
|
|
53
|
+
}
|
|
54
|
+
if (dir === root) return null;
|
|
55
|
+
const parent = dirname(dir);
|
|
56
|
+
if (parent === dir) return null;
|
|
57
|
+
dir = parent;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// True when input.cwd does not sit inside a project that has been `spotter install`-ed.
|
|
62
|
+
// Used by all 5 hooks to early-exit on unrelated `claude -p` invocations from other tools.
|
|
63
|
+
export function isOutsideSpotterProject(input) {
|
|
64
|
+
const cwd = input?.cwd;
|
|
65
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return true;
|
|
66
|
+
return findSpotterMarker(cwd) === null;
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
export async function readStdinJson() {
|
|
29
70
|
let raw = '';
|
|
30
71
|
process.stdin.setEncoding('utf8');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// PreToolUse hook — record tool usage in daemon (lightweight, no Haiku call). §9.1 v0.1.
|
|
2
2
|
// v0.2 gates: see src/hooks/session-start.mjs comment.
|
|
3
3
|
|
|
4
|
-
import { readStdinJson, requireString, exitCodeFor, die, isChildCall, isSubagentCall } from './lib.mjs';
|
|
4
|
+
import { readStdinJson, requireString, exitCodeFor, die, isChildCall, isSubagentCall, isOutsideSpotterProject } from './lib.mjs';
|
|
5
5
|
import { sendRequest } from '../daemon/transport.mjs';
|
|
6
6
|
|
|
7
7
|
const TIMEOUT_MS = 1_000;
|
|
@@ -10,6 +10,7 @@ export async function runPreToolUse() {
|
|
|
10
10
|
if (isChildCall()) return;
|
|
11
11
|
const input = await readStdinJson();
|
|
12
12
|
if (isSubagentCall(input)) return;
|
|
13
|
+
if (isOutsideSpotterProject(input)) return;
|
|
13
14
|
|
|
14
15
|
const sessionId = requireString(input, 'session_id');
|
|
15
16
|
const toolName = requireString(input, 'tool_name');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SessionEnd hook — best-effort shutdown notice. §14.1 exception: cleanup failures warn only.
|
|
2
2
|
// v0.2 gates: see src/hooks/session-start.mjs comment.
|
|
3
3
|
|
|
4
|
-
import { readStdinJson, requireString, isChildCall, isSubagentCall } from './lib.mjs';
|
|
4
|
+
import { readStdinJson, requireString, isChildCall, isSubagentCall, isOutsideSpotterProject } from './lib.mjs';
|
|
5
5
|
import { sendRequest } from '../daemon/transport.mjs';
|
|
6
6
|
|
|
7
7
|
const TIMEOUT_MS = 2_000;
|
|
@@ -10,6 +10,7 @@ export async function runSessionEnd() {
|
|
|
10
10
|
if (isChildCall()) return;
|
|
11
11
|
const input = await readStdinJson();
|
|
12
12
|
if (isSubagentCall(input)) return;
|
|
13
|
+
if (isOutsideSpotterProject(input)) return;
|
|
13
14
|
|
|
14
15
|
const sessionId = requireString(input, 'session_id');
|
|
15
16
|
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
// - isSubagentCall: Bell's Task subagent → exit 0 (not audited in v0.2)
|
|
8
8
|
// - source !== 'startup': /compact, /clear, --resume, --continue → exit 0
|
|
9
9
|
// (these continue an existing parent session; v0.2 does not migrate daemon state)
|
|
10
|
+
//
|
|
11
|
+
// v0.3 gate:
|
|
12
|
+
// - isOutsideSpotterProject: cwd has no .spotter/marker.json above it → exit 0
|
|
13
|
+
// (Throughline workdir etc. — `claude -p` from tools outside any installed project)
|
|
10
14
|
|
|
11
15
|
import { spawn } from 'node:child_process';
|
|
12
16
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
13
17
|
import { fileURLToPath } from 'node:url';
|
|
14
18
|
import { dirname, resolve } from 'node:path';
|
|
15
|
-
import { readStdinJson, requireString, die, isChildCall, isSubagentCall } from './lib.mjs';
|
|
19
|
+
import { readStdinJson, requireString, die, isChildCall, isSubagentCall, isOutsideSpotterProject } from './lib.mjs';
|
|
16
20
|
import { sendRequest, TransportError } from '../daemon/transport.mjs';
|
|
17
21
|
|
|
18
22
|
const READINESS_TIMEOUT_MS = 3_000;
|
|
@@ -30,6 +34,10 @@ export async function runSessionStart({ argv = process.argv, now = Date.now } =
|
|
|
30
34
|
// Gate 3: non-startup sources (resume/compact/clear) don't spawn a new daemon.
|
|
31
35
|
if (input.source !== 'startup') return;
|
|
32
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
|
+
if (isOutsideSpotterProject(input)) return;
|
|
40
|
+
|
|
33
41
|
const sessionId = requireString(input, 'session_id');
|
|
34
42
|
|
|
35
43
|
spawnDaemon(sessionId, argv);
|
package/src/hooks/stop.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
formatTransparentBlockReason,
|
|
12
12
|
isChildCall,
|
|
13
13
|
isSubagentCall,
|
|
14
|
+
isOutsideSpotterProject,
|
|
14
15
|
} from './lib.mjs';
|
|
15
16
|
import { sendRequest } from '../daemon/transport.mjs';
|
|
16
17
|
|
|
@@ -20,6 +21,7 @@ export async function runStop() {
|
|
|
20
21
|
if (isChildCall()) return;
|
|
21
22
|
const input = await readStdinJson();
|
|
22
23
|
if (isSubagentCall(input)) return;
|
|
24
|
+
if (isOutsideSpotterProject(input)) return;
|
|
23
25
|
|
|
24
26
|
const sessionId = requireString(input, 'session_id');
|
|
25
27
|
const stopHookActive = input.stop_hook_active === true;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
formatTransparentContext,
|
|
10
10
|
isChildCall,
|
|
11
11
|
isSubagentCall,
|
|
12
|
+
isOutsideSpotterProject,
|
|
12
13
|
} from './lib.mjs';
|
|
13
14
|
import { sendRequest } from '../daemon/transport.mjs';
|
|
14
15
|
|
|
@@ -18,6 +19,7 @@ export async function runUserPrompt() {
|
|
|
18
19
|
if (isChildCall()) return;
|
|
19
20
|
const input = await readStdinJson();
|
|
20
21
|
if (isSubagentCall(input)) return;
|
|
22
|
+
if (isOutsideSpotterProject(input)) return;
|
|
21
23
|
|
|
22
24
|
const sessionId = requireString(input, 'session_id');
|
|
23
25
|
const prompt = requireString(input, 'prompt');
|
package/src/version.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '0.
|
|
1
|
+
export const version = '0.3.0';
|