claude-spotter 0.1.1 → 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 CHANGED
@@ -1,6 +1,89 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1
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
+
41
+ ## 0.2.0
42
+
43
+ Fixes the v0.1.x daemon proliferation by adding multiple defence layers that together prevent any
44
+ non-parent-session hook from re-entering the daemon spawn path.
45
+
46
+ - **Env-var gate (`SPOTTER_PARENT_PID`)**: the daemon injects its own PID when spawning `claude -p`
47
+ for Haiku. Every hook checks this on startup and exits immediately when present — the primary
48
+ fix for Spotter's own subprocess recursion.
49
+ - **`agent_id` gate**: hooks fired inside a Task subagent carry `agent_id` per the Claude Code
50
+ hook contract. The hook entry-points exit on seeing this field, so subagent activity is never
51
+ audited (matches v0.2's scope: top-level parent sessions only).
52
+ - **`source === 'startup'` gate on SessionStart**: `/compact`, `/clear`, `--resume`, `--continue`
53
+ all fire SessionStart with a fresh session_id; without this gate they used to spawn a new
54
+ daemon. Now they no-op.
55
+ - **PID-preexist check**: `startDaemon` asserts no live daemon already serves the session_id,
56
+ throwing `DaemonAlreadyRunningError` so the caller exits cleanly.
57
+ - **10-second call window**: the daemon ignores `user_input` / `turn_end` events that arrive
58
+ within 10 s of its own Haiku spawn. Final safety net; documented trade-off (a brief window of
59
+ legitimate parent events may also be skipped).
60
+ - **Session-scoped Haiku conversation**: the daemon now generates one `haikuSessionId` UUID at
61
+ startup. The first Haiku call uses `claude -p --session-id <uuid>`; subsequent calls use
62
+ `--resume <uuid>`. The catalog is therefore sent to Haiku exactly once per parent session,
63
+ realising plan §5.4's original economic intent. Prompts now distinguish first vs incremental
64
+ form.
65
+ - **Mutex on Haiku calls**: a Promise chain serialises `callHaiku` so concurrent events cannot
66
+ race on the `haikuInitialized` flag and double-send the catalog.
67
+
68
+ ### Breaking
69
+
70
+ - `createHaikuCaller` now requires `haikuSessionId` (will throw without it).
71
+ - The returned `callHaiku` accepts `(prompt, { isFirst })`; callers that used `callHaiku(prompt)`
72
+ should pass `{ isFirst: true }` (the lint flow does this).
73
+ - `buildFirstStagePrompt` / `buildFinalStagePrompt` accept `isFirst` (defaults to true, so
74
+ existing callers that want full prompts continue working).
75
+
76
+ ### Experimental-flag note
77
+
78
+ `claude -p --bare` was evaluated as a fifth layer but errors with "Not logged in" because it
79
+ skips auth auto-discovery. `--bare` is therefore NOT used. The env-var gate plus the other four
80
+ layers cover the same proliferation cases.
81
+
82
+ ## 0.1.1 — ⚠️ DEPRECATED 2026-04-19
83
+
84
+ **Do not install this version.** Real-world testing against a live Claude Code session revealed that the "one daemon per session" model is based on a wrong assumption — `SessionStart` hooks fire per subagent (Task tool invocation), not only at top-level session startup. Within 41 seconds of install, 213 orphan daemons accumulated and Haiku API calls uniformly timed out. `npm uninstall -g` also did not execute `preuninstall`, leaving hook entries in `~/.claude/settings.json`. See [docs/spotter-plan.md §18](https://github.com/kitepon-rgb/Spotter/blob/main/docs/spotter-plan.md#18) for details and the v0.2 redesign plan.
85
+
86
+ ## 0.1.1 (pre-deprecation notes)
4
87
 
5
88
  - `npm install -g claude-spotter` now registers hooks at user level automatically via the `postinstall` lifecycle — no separate `spotter install` step needed
6
89
  - `npm uninstall -g claude-spotter` removes hook entries from `~/.claude/settings.json` via `preuninstall`
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Spotter
2
2
 
3
+ > **v0.2.0 released 2026-04-19**. v0.1.x had a daemon-proliferation defect caused by hook re-entry from Spotter's own `claude -p` invocations. v0.2 adds five independent gates (env-var PID, `agent_id`, `source=startup`, PID-preexist, 10 s call window) plus session-scoped Haiku via `--session-id` / `--resume` to prevent this. See [CHANGELOG](CHANGELOG.md) and [docs/spotter-plan.md §18](docs/spotter-plan.md) for the full post-mortem.
4
+
3
5
  **気づく役と実行する役を分離する。** Spotter は Claude Code の横で静かに並走し、Bell (主役の Claude) が**ツールを呼び忘れたとき**に指摘する監査役です。
4
6
 
5
7
  > Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。現在時刻を推測で答える、web_search を呼ばずに古い情報で応答する、read_file を使わずにファイルの中身を推測する — 「分からないと自覚できない」から、ツールを取りに行けない。
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 [--user] [-y] register hooks in .claude/settings.json
21
- spotter uninstall [--user] [-y] remove spotter hooks
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- // Never fails npm install on any error we warn and exit 0 so the user's
6
- // node_modules/global bin is still usable. They can re-run `spotter install --user`
7
- // manually if needed.
8
-
9
- import { runInstall } from '../src/cli/install.mjs';
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
- async function main() {
14
- if (process.env[SKIP_ENV]) {
15
- console.log(`claude-spotter: auto-install skipped (${SKIP_ENV} set).`);
16
- console.log(' run `spotter install --user` later to register hooks.');
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
- try {
29
- await runInstall({ target: 'user', autoYes: true });
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
- main().catch((err) => {
40
- // Defensive: never let postinstall crash the install.
41
- console.warn(`claude-spotter: postinstall unexpected error — ${err.message}`);
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.');
@@ -1,5 +1,9 @@
1
1
  // Runs before `npm uninstall (-g) claude-spotter`.
2
- // Removes hooks from ~/.claude/settings.json so an uninstall leaves the system clean.
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: hook cleanup skipped — ${err.message}`);
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.');
@@ -24,11 +24,13 @@ export async function runLint({ catalogPath, haikuCaller, writeLine }) {
24
24
  for (const tool of catalog.tools) {
25
25
  if (!Array.isArray(tool.test_cases)) continue;
26
26
  for (const tc of tool.test_cases) {
27
+ // Each test case is an independent judgement, so isFirst=true always.
27
28
  const prompt = buildFirstStagePrompt({
28
29
  catalog,
29
30
  userInput: tc.user_input,
31
+ isFirst: true,
30
32
  });
31
- const rawResponse = await haikuCaller(prompt);
33
+ const rawResponse = await haikuCaller(prompt, { isFirst: true });
32
34
  const parsed = parseHaikuResponse(rawResponse);
33
35
  const detectedNames = parsed.missing_tools.map((m) => m.name);
34
36
  const hit = detectedNames.includes(tc.expected_tool);
@@ -6,6 +6,7 @@
6
6
  import { spawn } from 'node:child_process';
7
7
  import { homedir } from 'node:os';
8
8
  import { join } from 'node:path';
9
+ import { randomUUID } from 'node:crypto';
9
10
  import { runLint } from '../catalog/lint.mjs';
10
11
  import { createHaikuCaller } from '../daemon/haiku-caller.mjs';
11
12
 
@@ -25,7 +26,16 @@ function defaultEditor() {
25
26
  }
26
27
 
27
28
  export async function runCatalogLint({ catalogPath = CATALOG_PATH } = {}) {
28
- const haikuCaller = createHaikuCaller({ timeoutMs: 30_000 });
29
+ // Each test case must be independent, so generate a fresh Haiku session per call.
30
+ // (Reusing one session-id across cases would have Haiku's context carry judgements
31
+ // from earlier cases, contaminating later ones.)
32
+ const haikuCaller = async (prompt, opts = {}) => {
33
+ const caller = createHaikuCaller({
34
+ timeoutMs: 30_000,
35
+ haikuSessionId: randomUUID(),
36
+ });
37
+ return await caller(prompt, { ...opts, isFirst: true });
38
+ };
29
39
  const result = await runLint({
30
40
  catalogPath,
31
41
  haikuCaller,
@@ -1,6 +1,6 @@
1
1
  // `spotter daemon start|stop` — internal commands invoked by SessionStart/SessionEnd hooks.
2
2
 
3
- import { startDaemon } from '../daemon/daemon.mjs';
3
+ 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';
@@ -32,7 +32,19 @@ export async function runDaemonStart({ argv }) {
32
32
  logFile.write(line).catch(() => {});
33
33
  };
34
34
 
35
- const running = await startDaemon({ sessionId, logFn: log });
35
+ let running;
36
+ try {
37
+ running = await startDaemon({ sessionId, logFn: log, warmup: true });
38
+ } catch (err) {
39
+ if (err instanceof DaemonAlreadyRunningError) {
40
+ // v0.2 PID-preexist layer: a sibling daemon already serves this session.
41
+ // Exit cleanly so the hook's readiness poll finds the existing one.
42
+ log(`startup skipped: ${err.message}`);
43
+ await logFile.close();
44
+ process.exit(0);
45
+ }
46
+ throw err;
47
+ }
36
48
  log(`started on ${running.path}`);
37
49
 
38
50
  // Keep process alive; SessionEnd → shutdown event triggers server.close() which resolves the await.
@@ -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. compute desired settings.json with hooks
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);
@@ -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
+ }
@@ -1,14 +1,27 @@
1
1
  // Session-scoped daemon — receives hook events, dispatches to handlers,
2
2
  // calls Haiku on user_input / turn_end, keeps used_tools in process memory.
3
3
  //
4
- // §5.4: Claude calls are stateless per turn; process memory holds only lightweight state.
5
- // §5.7: event dispatch is defined per the envelope contract.
6
- // §14: unexpected errors are thrown; hooks convert them to exit codes.
4
+ // §5.4: the Haiku conversation is session-scoped (one per parent session), realised via
5
+ // --session-id (first call) and --resume (subsequent). The catalog is therefore
6
+ // transmitted once in the first Haiku call; later calls only send incremental info.
7
+ // §5.7: event dispatch follows the envelope contract.
8
+ // §14: unexpected errors are thrown; hooks convert them to exit codes.
9
+ //
10
+ // v0.2 defence layers against daemon proliferation (see plan §18 / C2 verification log):
11
+ // - SPOTTER_PARENT_PID env var (set by haiku-caller when spawning claude -p; hooks skip on presence)
12
+ // - agent_id gate (subagent hooks exit 0 before reaching the daemon)
13
+ // - source='startup' gate (session-start hook only spawns daemon for startup sources)
14
+ // - PID preexist check (if a live daemon already serves this session_id, new attempt exits)
15
+ // - 10-second call window (inside the daemon, ignore Haiku-invoking events that arrived within
16
+ // 10s of our own claude -p spawn — final safety net against any recursion that slipped past
17
+ // the env-var gate)
7
18
 
19
+ import { readFile } from 'node:fs/promises';
8
20
  import { createServer, ensureRuntimeDir, socketPath } from './transport.mjs';
9
21
  import {
10
22
  buildFirstStagePrompt,
11
23
  buildFinalStagePrompt,
24
+ buildWarmupPrompt,
12
25
  parseHaikuResponse,
13
26
  createHaikuCaller,
14
27
  } from './haiku-caller.mjs';
@@ -16,14 +29,27 @@ import { loadCatalog } from '../catalog/loader.mjs';
16
29
  import { homedir } from 'node:os';
17
30
  import { join } from 'node:path';
18
31
  import { writeFile, unlink } from 'node:fs/promises';
32
+ import { randomUUID } from 'node:crypto';
19
33
 
20
34
  const DEFAULT_CATALOG_PATH = join(homedir(), '.spotter', 'tool-catalog', 'tools.yaml');
35
+ const HAIKU_CALL_WINDOW_MS = 10_000;
36
+
37
+ export class DaemonAlreadyRunningError extends Error {
38
+ constructor(sessionId, pid) {
39
+ super(`daemon for session ${sessionId} already running (pid=${pid})`);
40
+ this.name = 'DaemonAlreadyRunningError';
41
+ this.sessionId = sessionId;
42
+ this.pid = pid;
43
+ }
44
+ }
21
45
 
22
46
  export async function startDaemon({
23
47
  sessionId,
24
48
  catalogPath = DEFAULT_CATALOG_PATH,
25
49
  haikuCaller,
50
+ haikuSessionId,
26
51
  logFn = () => {},
52
+ warmup = false,
27
53
  } = {}) {
28
54
  if (!sessionId) {
29
55
  throw new TypeError('sessionId is required');
@@ -31,12 +57,22 @@ export async function startDaemon({
31
57
 
32
58
  await ensureRuntimeDir();
33
59
 
60
+ // Layer: preexisting-daemon detection. If a PID file exists AND that process is alive,
61
+ // a sibling daemon is already serving this session_id — throw so the caller can exit.
62
+ await assertNoLiveDaemon(sessionId);
63
+
34
64
  // Load catalog up front — daemon cannot run without it (§14.1).
35
65
  const catalog = await loadCatalog(catalogPath);
36
66
  logFn(`catalog loaded: ${catalog.tools.length} tools from ${catalogPath}`);
37
67
 
38
- // Default Haiku caller: timeout below the hook timeout for user_input/turn_end (§5.7).
39
- const callHaiku = haikuCaller ?? createHaikuCaller({ timeoutMs: 28_000 });
68
+ // Per-daemon Haiku conversation id. Same UUID is used for --session-id (first)
69
+ // and --resume (subsequent), so Haiku retains the catalog/rules across calls.
70
+ const ownHaikuSessionId = haikuSessionId ?? randomUUID();
71
+
72
+ const callHaiku = haikuCaller ?? createHaikuCaller({
73
+ timeoutMs: 28_000,
74
+ haikuSessionId: ownHaikuSessionId,
75
+ });
40
76
 
41
77
  // Per-turn state, reset on turn_end.
42
78
  const state = {
@@ -44,6 +80,30 @@ export async function startDaemon({
44
80
  lastUserInput: null,
45
81
  };
46
82
 
83
+ // Haiku call serialisation + bookkeeping.
84
+ // Serialisation prevents two concurrent incoming events from both computing isFirst=true
85
+ // and double-sending the catalog (audit H2).
86
+ let haikuInitialized = false;
87
+ let lastHaikuCallAt = 0;
88
+ let haikuChain = Promise.resolve();
89
+
90
+ const callHaikuTracked = (buildPrompt) => {
91
+ const run = async () => {
92
+ lastHaikuCallAt = Date.now();
93
+ const isFirst = !haikuInitialized;
94
+ const prompt = buildPrompt({ isFirst });
95
+ const raw = await callHaiku(prompt, { isFirst });
96
+ // Only flip to initialised after a successful call so a failed first call is retried
97
+ // (still as first) rather than leaving Haiku with no catalog/rules in its context.
98
+ haikuInitialized = true;
99
+ return raw;
100
+ };
101
+ // Chain onto the previous call; whether it resolved or rejected, we run next.
102
+ const next = haikuChain.then(run, run);
103
+ haikuChain = next.catch(() => {}); // swallow so chain survives rejections
104
+ return next;
105
+ };
106
+
47
107
  const handler = async (envelope) => {
48
108
  if (!envelope || typeof envelope !== 'object') {
49
109
  const err = new Error('invalid envelope');
@@ -55,6 +115,16 @@ export async function startDaemon({
55
115
  err.code = 'E_INTERNAL';
56
116
  throw err;
57
117
  }
118
+
119
+ // 10-second window safety net: events that would invoke Haiku within 10s of our own
120
+ // claude -p spawn are likely recursive noise; pass them quietly.
121
+ const needsHaiku = envelope.event === 'user_input' || envelope.event === 'turn_end';
122
+ const sinceLast = Date.now() - lastHaikuCallAt;
123
+ if (needsHaiku && lastHaikuCallAt > 0 && sinceLast < HAIKU_CALL_WINDOW_MS) {
124
+ logFn(`${envelope.event} skipped: within ${sinceLast}ms of own haiku call`);
125
+ return { pass: true, missing_tools: [], reason: 'within_haiku_call_window' };
126
+ }
127
+
58
128
  switch (envelope.event) {
59
129
  case 'readiness':
60
130
  return { ready: true };
@@ -85,8 +155,9 @@ export async function startDaemon({
85
155
  state.lastUserInput = userInput;
86
156
  state.usedTools = []; // reset tools for this turn
87
157
 
88
- const prompt = buildFirstStagePrompt({ catalog, userInput });
89
- const raw = await callHaiku(prompt);
158
+ const raw = await callHaikuTracked(({ isFirst }) =>
159
+ buildFirstStagePrompt({ catalog, userInput, isFirst })
160
+ );
90
161
  const parsed = parseHaikuResponse(raw);
91
162
  logFn(`user_input: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
92
163
  return parsed;
@@ -112,25 +183,27 @@ export async function startDaemon({
112
183
  throw err;
113
184
  }
114
185
  if (payload.stop_hook_active === true) {
115
- // Spotter already intervened this turn — §7.5/§8.1 max-1-loop guarantee.
116
186
  logFn('turn_end: stop_hook_active=true, passing');
117
187
  state.usedTools = [];
118
188
  state.lastUserInput = null;
119
189
  return { pass: true, missing_tools: [], reason: 'stop_hook_active' };
120
190
  }
121
191
  if (state.lastUserInput === null) {
122
- // No user_input seen this turn — nothing to audit against. Pass quietly.
123
192
  logFn('turn_end: no user_input observed, passing');
124
193
  return { pass: true, missing_tools: [], reason: 'no_user_input' };
125
194
  }
126
195
 
127
- const prompt = buildFinalStagePrompt({
128
- catalog,
129
- userInput: state.lastUserInput,
130
- usedTools: state.usedTools,
131
- finalResponse,
132
- });
133
- const raw = await callHaiku(prompt);
196
+ const savedUserInput = state.lastUserInput;
197
+ const savedUsedTools = state.usedTools.slice();
198
+ const raw = await callHaikuTracked(({ isFirst }) =>
199
+ buildFinalStagePrompt({
200
+ catalog,
201
+ userInput: savedUserInput,
202
+ usedTools: savedUsedTools,
203
+ finalResponse,
204
+ isFirst,
205
+ })
206
+ );
134
207
  const parsed = parseHaikuResponse(raw);
135
208
  logFn(`turn_end: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
136
209
 
@@ -149,7 +222,7 @@ export async function startDaemon({
149
222
  await new Promise((resolve, reject) => {
150
223
  server.on('error', (err) => reject(err));
151
224
  server.listen(path, () => {
152
- logFn(`daemon listening on ${path}`);
225
+ logFn(`daemon listening on ${path} (haikuSessionId=${ownHaikuSessionId})`);
153
226
  resolve();
154
227
  });
155
228
  });
@@ -158,22 +231,71 @@ export async function startDaemon({
158
231
  const pidPath = pidFilePath(sessionId);
159
232
  await writeFile(pidPath, String(process.pid), 'utf8');
160
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
+
161
259
  return {
162
260
  server,
163
261
  path,
164
262
  pidPath,
263
+ haikuSessionId: ownHaikuSessionId,
264
+ warmupPromise,
165
265
  stop: () => shutdown(server, sessionId, logFn),
166
266
  };
167
267
  }
168
268
 
269
+ async function assertNoLiveDaemon(sessionId) {
270
+ const pidPath = pidFilePath(sessionId);
271
+ let raw;
272
+ try {
273
+ raw = await readFile(pidPath, 'utf8');
274
+ } catch (err) {
275
+ if (err.code === 'ENOENT') return;
276
+ throw err;
277
+ }
278
+ const pid = parseInt(raw.trim(), 10);
279
+ if (!Number.isFinite(pid)) return; // stale/malformed — treat as absent
280
+ try {
281
+ process.kill(pid, 0);
282
+ } catch (err) {
283
+ if (err.code === 'ESRCH') return; // process gone; stale PID file is fine
284
+ if (err.code === 'EPERM') {
285
+ // running under another user — still counts as live
286
+ throw new DaemonAlreadyRunningError(sessionId, pid);
287
+ }
288
+ return;
289
+ }
290
+ throw new DaemonAlreadyRunningError(sessionId, pid);
291
+ }
292
+
169
293
  async function shutdown(server, sessionId, logFn) {
170
294
  try {
171
295
  await new Promise((resolve) => server.close(resolve));
172
296
  } catch (err) {
173
- // SessionEnd cleanup failures are §14.1 exceptions — warn only.
174
297
  logFn(`shutdown: server.close failed: ${err.message}`);
175
298
  }
176
- // On Unix, remove the socket file. On Windows, Named Pipes are auto-cleaned.
177
299
  if (process.platform !== 'win32') {
178
300
  try {
179
301
  await unlink(socketPath(sessionId));
@@ -24,7 +24,17 @@ export async function ensureWorkdir() {
24
24
  }
25
25
 
26
26
  // Build the first-stage prompt — projection of catalog purpose/when_to_use only.
27
- export function buildFirstStagePrompt({ catalog, userInput }) {
27
+ // When `isFirst` is true, includes system rules + full catalog (used with --session-id).
28
+ // When false, sends only incremental input (used with --resume; Haiku already has catalog/rules).
29
+ export function buildFirstStagePrompt({ catalog, userInput, isFirst = true }) {
30
+ if (!isFirst) {
31
+ return [
32
+ '## 新しいユーザー入力',
33
+ userInput,
34
+ '',
35
+ '既に共有済みの判定ルール・カタログに従い、同一 JSON スキーマで結果を返してください。',
36
+ ].join('\n');
37
+ }
28
38
  const toolsProjection = catalog.tools.map((t) => ({
29
39
  name: t.name,
30
40
  purpose: t.purpose,
@@ -46,7 +56,24 @@ export function buildFirstStagePrompt({ catalog, userInput }) {
46
56
  }
47
57
 
48
58
  // Build the final-stage prompt — Stop hook, after Bell's response.
49
- export function buildFinalStagePrompt({ catalog, userInput, usedTools, finalResponse }) {
59
+ // Incremental form (isFirst=false) omits the catalog since Haiku's resumed session already has it.
60
+ export function buildFinalStagePrompt({ catalog, userInput, usedTools, finalResponse, isFirst = true }) {
61
+ if (!isFirst) {
62
+ return [
63
+ '## ターン終了判定',
64
+ '',
65
+ '### 対象ユーザー入力',
66
+ userInput,
67
+ '',
68
+ '### Bell が既に使用したツール',
69
+ usedTools.length > 0 ? usedTools.map((t) => `- ${t}`).join('\n') : '(なし)',
70
+ '',
71
+ '### Bell の最終応答',
72
+ finalResponse,
73
+ '',
74
+ '既に共有済みのルールに従い、使用済みツールは除外した上で同一 JSON スキーマで結果を返してください。',
75
+ ].join('\n');
76
+ }
50
77
  const toolsProjection = catalog.tools.map((t) => ({
51
78
  name: t.name,
52
79
  purpose: t.purpose,
@@ -74,6 +101,30 @@ export function buildFinalStagePrompt({ catalog, userInput, usedTools, finalResp
74
101
  ].join('\n');
75
102
  }
76
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
+
77
128
  function systemRules() {
78
129
  return [
79
130
  'あなたは Spotter — Claude (Bell) が呼び忘れているツールを検出する監査役です。',
@@ -154,8 +205,14 @@ function truncate(s, n = 300) {
154
205
  // On Windows, the `claude` entry is typically a .cmd shim which Node's spawn
155
206
  // cannot locate without going through the shell. We use cmd.exe /c explicitly
156
207
  // rather than spawn({ shell: true }) because the latter triggers DEP0190 on Node 24+.
157
- function buildSpawnArgs(claudeBin, model) {
158
- const args = ['-p', '--model', model];
208
+ //
209
+ // v0.2: For the first call of a daemon's lifetime, spawn with `--session-id <haikuSessionId>`
210
+ // to create a new Haiku conversation. For subsequent calls, spawn with `--resume <haikuSessionId>`
211
+ // to continue that same conversation (so catalog/system rules persist in Haiku's context).
212
+ // Note: `--bare` was tried but fails with "Not logged in" — it is intentionally NOT used.
213
+ function buildSpawnArgs(claudeBin, model, haikuSessionId, isFirstCall) {
214
+ const sessionFlag = isFirstCall ? '--session-id' : '--resume';
215
+ const args = ['-p', sessionFlag, haikuSessionId, '--model', model];
159
216
  if (process.platform === 'win32') {
160
217
  return { cmd: 'cmd.exe', cmdArgs: ['/c', claudeBin, ...args] };
161
218
  }
@@ -164,18 +221,25 @@ function buildSpawnArgs(claudeBin, model) {
164
221
 
165
222
  // Invoke `claude -p` in the isolated workdir. Returns raw stdout.
166
223
  // §5.5: no retry on failure. §14.1: silent fallback forbidden.
167
- export function createHaikuCaller({ timeoutMs, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
224
+ //
225
+ // v0.2: `haikuSessionId` is required — used for --session-id (first call) / --resume (subsequent).
226
+ // The SPOTTER_PARENT_PID env var is always injected so hooks firing inside the spawned claude
227
+ // exit early via isChildCall() (prevents daemon-spawn recursion).
228
+ export function createHaikuCaller({ timeoutMs, haikuSessionId, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
168
229
  if (typeof timeoutMs !== 'number' || timeoutMs <= 0) {
169
230
  throw new TypeError('timeoutMs must be a positive number');
170
231
  }
232
+ if (typeof haikuSessionId !== 'string' || haikuSessionId.length === 0) {
233
+ throw new TypeError('haikuSessionId is required (non-empty string)');
234
+ }
171
235
 
172
- return async function callHaiku(prompt) {
236
+ return async function callHaiku(prompt, { isFirst = true } = {}) {
173
237
  await ensureWorkdir();
174
238
  return new Promise((resolve, reject) => {
175
- const { cmd, cmdArgs } = buildSpawnArgs(claudeBin, model);
239
+ const { cmd, cmdArgs } = buildSpawnArgs(claudeBin, model, haikuSessionId, isFirst);
176
240
  const child = spawn(cmd, cmdArgs, {
177
241
  cwd: WORKDIR,
178
- env,
242
+ env: { ...env, SPOTTER_PARENT_PID: String(process.pid) },
179
243
  stdio: ['pipe', 'pipe', 'pipe'],
180
244
  windowsHide: true,
181
245
  });
package/src/hooks/lib.mjs CHANGED
@@ -6,6 +6,65 @@
6
6
  // 2 = unexpected (propagate to Claude Code transcript)
7
7
  //
8
8
  // Silent fallback (exit 0 with missing behaviour) is forbidden. See §14.1.
9
+ //
10
+ // v0.2 gate helpers (plan §18 / C:\Users\kite_\.claude\plans\10-cuddly-codd.md):
11
+ // - isChildCall(): env-var gate for Spotter's own claude -p invocations
12
+ // - isSubagentCall(input): agent_id gate for Bell's Task subagent hooks
13
+ // Combined with session-start's source='startup' check, these prevent daemon
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';
23
+
24
+ export function isChildCall() {
25
+ const v = process.env.SPOTTER_PARENT_PID;
26
+ return typeof v === 'string' && v.length > 0;
27
+ }
28
+
29
+ export function isSubagentCall(input) {
30
+ return input !== null
31
+ && typeof input === 'object'
32
+ && typeof input.agent_id === 'string'
33
+ && input.agent_id.length > 0;
34
+ }
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
+ }
9
68
 
10
69
  export async function readStdinJson() {
11
70
  let raw = '';
@@ -1,12 +1,17 @@
1
1
  // PreToolUse hook — record tool usage in daemon (lightweight, no Haiku call). §9.1 v0.1.
2
+ // v0.2 gates: see src/hooks/session-start.mjs comment.
2
3
 
3
- import { readStdinJson, requireString, exitCodeFor, die } from './lib.mjs';
4
+ import { readStdinJson, requireString, exitCodeFor, die, isChildCall, isSubagentCall, isOutsideSpotterProject } from './lib.mjs';
4
5
  import { sendRequest } from '../daemon/transport.mjs';
5
6
 
6
7
  const TIMEOUT_MS = 1_000;
7
8
 
8
9
  export async function runPreToolUse() {
10
+ if (isChildCall()) return;
9
11
  const input = await readStdinJson();
12
+ if (isSubagentCall(input)) return;
13
+ if (isOutsideSpotterProject(input)) return;
14
+
10
15
  const sessionId = requireString(input, 'session_id');
11
16
  const toolName = requireString(input, 'tool_name');
12
17
 
@@ -1,12 +1,17 @@
1
1
  // SessionEnd hook — best-effort shutdown notice. §14.1 exception: cleanup failures warn only.
2
+ // v0.2 gates: see src/hooks/session-start.mjs comment.
2
3
 
3
- import { readStdinJson, requireString } from './lib.mjs';
4
+ import { readStdinJson, requireString, isChildCall, isSubagentCall, isOutsideSpotterProject } from './lib.mjs';
4
5
  import { sendRequest } from '../daemon/transport.mjs';
5
6
 
6
7
  const TIMEOUT_MS = 2_000;
7
8
 
8
9
  export async function runSessionEnd() {
10
+ if (isChildCall()) return;
9
11
  const input = await readStdinJson();
12
+ if (isSubagentCall(input)) return;
13
+ if (isOutsideSpotterProject(input)) return;
14
+
10
15
  const sessionId = requireString(input, 'session_id');
11
16
 
12
17
  try {
@@ -1,19 +1,43 @@
1
1
  // SessionStart hook — spawn daemon detached, wait up to 3s for readiness (§9.1).
2
2
  //
3
3
  // §14.3 classifies readiness failure as unexpected (exit 2). §14.1 forbids silent fallback.
4
+ //
5
+ // v0.2 gates (plan §18, C:\Users\kite_\.claude\plans\10-cuddly-codd.md):
6
+ // - isChildCall: Spotter's own claude -p subprocess → exit 0 (prevents recursion)
7
+ // - isSubagentCall: Bell's Task subagent → exit 0 (not audited in v0.2)
8
+ // - source !== 'startup': /compact, /clear, --resume, --continue → exit 0
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)
4
14
 
5
15
  import { spawn } from 'node:child_process';
6
16
  import { setTimeout as delay } from 'node:timers/promises';
7
17
  import { fileURLToPath } from 'node:url';
8
18
  import { dirname, resolve } from 'node:path';
9
- import { readStdinJson, requireString, die } from './lib.mjs';
19
+ import { readStdinJson, requireString, die, isChildCall, isSubagentCall, isOutsideSpotterProject } from './lib.mjs';
10
20
  import { sendRequest, TransportError } from '../daemon/transport.mjs';
11
21
 
12
22
  const READINESS_TIMEOUT_MS = 3_000;
13
23
  const POLL_INTERVAL_MS = 100;
14
24
 
15
25
  export async function runSessionStart({ argv = process.argv, now = Date.now } = {}) {
26
+ // Gate 1 (pre-stdin): Spotter's own claude -p subprocess — exit without reading stdin.
27
+ if (isChildCall()) return;
28
+
16
29
  const input = await readStdinJson();
30
+
31
+ // Gate 2: Task subagent — skip audit.
32
+ if (isSubagentCall(input)) return;
33
+
34
+ // Gate 3: non-startup sources (resume/compact/clear) don't spawn a new daemon.
35
+ 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
+ if (isOutsideSpotterProject(input)) return;
40
+
17
41
  const sessionId = requireString(input, 'session_id');
18
42
 
19
43
  spawnDaemon(sessionId, argv);
@@ -1,5 +1,6 @@
1
1
  // Stop hook — send turn_end, return decision:"block" on miss (§12.3 transparent).
2
2
  // `stop_hook_active: true` → daemon returns pass automatically (§7.5 max-1-loop).
3
+ // v0.2 gates: see src/hooks/session-start.mjs comment.
3
4
 
4
5
  import {
5
6
  readStdinJson,
@@ -8,13 +9,20 @@ import {
8
9
  exitCodeFor,
9
10
  die,
10
11
  formatTransparentBlockReason,
12
+ isChildCall,
13
+ isSubagentCall,
14
+ isOutsideSpotterProject,
11
15
  } from './lib.mjs';
12
16
  import { sendRequest } from '../daemon/transport.mjs';
13
17
 
14
18
  const TIMEOUT_MS = 15_000;
15
19
 
16
20
  export async function runStop() {
21
+ if (isChildCall()) return;
17
22
  const input = await readStdinJson();
23
+ if (isSubagentCall(input)) return;
24
+ if (isOutsideSpotterProject(input)) return;
25
+
18
26
  const sessionId = requireString(input, 'session_id');
19
27
  const stopHookActive = input.stop_hook_active === true;
20
28
  // Claude Code passes the transcript path; the final response is read from there or provided inline.
@@ -1,12 +1,26 @@
1
1
  // UserPromptSubmit hook — send user_input to daemon, inject additionalContext (§12.2 transparent).
2
-
3
- import { readStdinJson, requireString, exitCodeFor, die, formatTransparentContext } from './lib.mjs';
2
+ // v0.2 gates: see src/hooks/session-start.mjs comment.
3
+
4
+ import {
5
+ readStdinJson,
6
+ requireString,
7
+ exitCodeFor,
8
+ die,
9
+ formatTransparentContext,
10
+ isChildCall,
11
+ isSubagentCall,
12
+ isOutsideSpotterProject,
13
+ } from './lib.mjs';
4
14
  import { sendRequest } from '../daemon/transport.mjs';
5
15
 
6
16
  const TIMEOUT_MS = 30_000;
7
17
 
8
18
  export async function runUserPrompt() {
19
+ if (isChildCall()) return;
9
20
  const input = await readStdinJson();
21
+ if (isSubagentCall(input)) return;
22
+ if (isOutsideSpotterProject(input)) return;
23
+
10
24
  const sessionId = requireString(input, 'session_id');
11
25
  const prompt = requireString(input, 'prompt');
12
26
 
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const version = '0.1.1';
1
+ export const version = '0.3.0';