claude-spotter 0.1.0 → 0.2.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,57 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (Unreleased)
3
+ ## 0.2.0
4
+
5
+ Fixes the v0.1.x daemon proliferation by adding multiple defence layers that together prevent any
6
+ non-parent-session hook from re-entering the daemon spawn path.
7
+
8
+ - **Env-var gate (`SPOTTER_PARENT_PID`)**: the daemon injects its own PID when spawning `claude -p`
9
+ for Haiku. Every hook checks this on startup and exits immediately when present — the primary
10
+ fix for Spotter's own subprocess recursion.
11
+ - **`agent_id` gate**: hooks fired inside a Task subagent carry `agent_id` per the Claude Code
12
+ hook contract. The hook entry-points exit on seeing this field, so subagent activity is never
13
+ audited (matches v0.2's scope: top-level parent sessions only).
14
+ - **`source === 'startup'` gate on SessionStart**: `/compact`, `/clear`, `--resume`, `--continue`
15
+ all fire SessionStart with a fresh session_id; without this gate they used to spawn a new
16
+ daemon. Now they no-op.
17
+ - **PID-preexist check**: `startDaemon` asserts no live daemon already serves the session_id,
18
+ throwing `DaemonAlreadyRunningError` so the caller exits cleanly.
19
+ - **10-second call window**: the daemon ignores `user_input` / `turn_end` events that arrive
20
+ within 10 s of its own Haiku spawn. Final safety net; documented trade-off (a brief window of
21
+ legitimate parent events may also be skipped).
22
+ - **Session-scoped Haiku conversation**: the daemon now generates one `haikuSessionId` UUID at
23
+ startup. The first Haiku call uses `claude -p --session-id <uuid>`; subsequent calls use
24
+ `--resume <uuid>`. The catalog is therefore sent to Haiku exactly once per parent session,
25
+ realising plan §5.4's original economic intent. Prompts now distinguish first vs incremental
26
+ form.
27
+ - **Mutex on Haiku calls**: a Promise chain serialises `callHaiku` so concurrent events cannot
28
+ race on the `haikuInitialized` flag and double-send the catalog.
29
+
30
+ ### Breaking
31
+
32
+ - `createHaikuCaller` now requires `haikuSessionId` (will throw without it).
33
+ - The returned `callHaiku` accepts `(prompt, { isFirst })`; callers that used `callHaiku(prompt)`
34
+ should pass `{ isFirst: true }` (the lint flow does this).
35
+ - `buildFirstStagePrompt` / `buildFinalStagePrompt` accept `isFirst` (defaults to true, so
36
+ existing callers that want full prompts continue working).
37
+
38
+ ### Experimental-flag note
39
+
40
+ `claude -p --bare` was evaluated as a fifth layer but errors with "Not logged in" because it
41
+ skips auth auto-discovery. `--bare` is therefore NOT used. The env-var gate plus the other four
42
+ layers cover the same proliferation cases.
43
+
44
+ ## 0.1.1 — ⚠️ DEPRECATED 2026-04-19
45
+
46
+ **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.
47
+
48
+ ## 0.1.1 (pre-deprecation notes)
49
+
50
+ - `npm install -g claude-spotter` now registers hooks at user level automatically via the `postinstall` lifecycle — no separate `spotter install` step needed
51
+ - `npm uninstall -g claude-spotter` removes hook entries from `~/.claude/settings.json` via `preuninstall`
52
+ - Opt-out: set `CLAUDE_SPOTTER_NO_AUTO_INSTALL=1` before install, or install in an environment where `CI=true` (CI is auto-skipped)
53
+
54
+ ## 0.1.0
4
55
 
5
56
  Initial release.
6
57
 
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 を使わずにファイルの中身を推測する — 「分からないと自覚できない」から、ツールを取りに行けない。
@@ -10,10 +12,11 @@ Spotter は、ツールカタログを完全に把握した別エージェント
10
12
 
11
13
  ```bash
12
14
  npm install -g claude-spotter
13
- spotter install # .claude/settings.json に hook を登録 (diff を見せて確認)
14
15
  ```
15
16
 
16
- 次回 Claude Code セッションから自動で有効になります。
17
+ これだけで `~/.claude/settings.json` に hook が自動登録され、次回 Claude Code セッションから全プロジェクトで有効になります (`postinstall` ライフサイクルが `spotter install --user` を自動実行します)。
18
+
19
+ 自動登録を止めたい場合は `CLAUDE_SPOTTER_NO_AUTO_INSTALL=1 npm install -g claude-spotter` と打てばスキップされ、あとから `spotter install` / `spotter install --user` を好きなタイミングで手動実行できます。CI 環境 (`CI=true`) では自動的にスキップされます。
17
20
 
18
21
  ## 動作要件
19
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,9 @@
10
10
  ".": "./src/index.mjs"
11
11
  },
12
12
  "scripts": {
13
- "test": "node --test"
13
+ "test": "node --test",
14
+ "postinstall": "node scripts/postinstall.mjs",
15
+ "preuninstall": "node scripts/preuninstall.mjs"
14
16
  },
15
17
  "keywords": [
16
18
  "claude",
@@ -40,6 +42,7 @@
40
42
  "files": [
41
43
  "bin",
42
44
  "src",
45
+ "scripts",
43
46
  "templates",
44
47
  "README.md",
45
48
  "LICENSE",
@@ -0,0 +1,42 @@
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
+ //
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';
10
+
11
+ const SKIP_ENV = 'CLAUDE_SPOTTER_NO_AUTO_INSTALL';
12
+
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
+ }
27
+
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
+ }
37
+ }
38
+
39
+ main().catch((err) => {
40
+ // Defensive: never let postinstall crash the install.
41
+ console.warn(`claude-spotter: postinstall unexpected error — ${err.message}`);
42
+ });
@@ -0,0 +1,14 @@
1
+ // Runs before `npm uninstall (-g) claude-spotter`.
2
+ // Removes hooks from ~/.claude/settings.json so an uninstall leaves the system clean.
3
+ //
4
+ // Never fails the uninstall — on error we warn and exit 0.
5
+
6
+ import { runUninstall } from '../src/cli/uninstall.mjs';
7
+
8
+ try {
9
+ await runUninstall({ target: 'user', autoYes: true });
10
+ console.log('claude-spotter: hooks removed from ~/.claude/settings.json.');
11
+ } catch (err) {
12
+ console.warn(`claude-spotter: hook cleanup skipped — ${err.message}`);
13
+ console.warn(' you may need to edit ~/.claude/settings.json manually.');
14
+ }
@@ -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 });
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,10 +1,22 @@
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,
@@ -16,13 +28,25 @@ import { loadCatalog } from '../catalog/loader.mjs';
16
28
  import { homedir } from 'node:os';
17
29
  import { join } from 'node:path';
18
30
  import { writeFile, unlink } from 'node:fs/promises';
31
+ import { randomUUID } from 'node:crypto';
19
32
 
20
33
  const DEFAULT_CATALOG_PATH = join(homedir(), '.spotter', 'tool-catalog', 'tools.yaml');
34
+ const HAIKU_CALL_WINDOW_MS = 10_000;
35
+
36
+ export class DaemonAlreadyRunningError extends Error {
37
+ constructor(sessionId, pid) {
38
+ super(`daemon for session ${sessionId} already running (pid=${pid})`);
39
+ this.name = 'DaemonAlreadyRunningError';
40
+ this.sessionId = sessionId;
41
+ this.pid = pid;
42
+ }
43
+ }
21
44
 
22
45
  export async function startDaemon({
23
46
  sessionId,
24
47
  catalogPath = DEFAULT_CATALOG_PATH,
25
48
  haikuCaller,
49
+ haikuSessionId,
26
50
  logFn = () => {},
27
51
  } = {}) {
28
52
  if (!sessionId) {
@@ -31,12 +55,22 @@ export async function startDaemon({
31
55
 
32
56
  await ensureRuntimeDir();
33
57
 
58
+ // Layer: preexisting-daemon detection. If a PID file exists AND that process is alive,
59
+ // a sibling daemon is already serving this session_id — throw so the caller can exit.
60
+ await assertNoLiveDaemon(sessionId);
61
+
34
62
  // Load catalog up front — daemon cannot run without it (§14.1).
35
63
  const catalog = await loadCatalog(catalogPath);
36
64
  logFn(`catalog loaded: ${catalog.tools.length} tools from ${catalogPath}`);
37
65
 
38
- // Default Haiku caller: timeout below the hook timeout for user_input/turn_end (§5.7).
39
- const callHaiku = haikuCaller ?? createHaikuCaller({ timeoutMs: 28_000 });
66
+ // Per-daemon Haiku conversation id. Same UUID is used for --session-id (first)
67
+ // and --resume (subsequent), so Haiku retains the catalog/rules across calls.
68
+ const ownHaikuSessionId = haikuSessionId ?? randomUUID();
69
+
70
+ const callHaiku = haikuCaller ?? createHaikuCaller({
71
+ timeoutMs: 28_000,
72
+ haikuSessionId: ownHaikuSessionId,
73
+ });
40
74
 
41
75
  // Per-turn state, reset on turn_end.
42
76
  const state = {
@@ -44,6 +78,30 @@ export async function startDaemon({
44
78
  lastUserInput: null,
45
79
  };
46
80
 
81
+ // Haiku call serialisation + bookkeeping.
82
+ // Serialisation prevents two concurrent incoming events from both computing isFirst=true
83
+ // and double-sending the catalog (audit H2).
84
+ let haikuInitialized = false;
85
+ let lastHaikuCallAt = 0;
86
+ let haikuChain = Promise.resolve();
87
+
88
+ const callHaikuTracked = (buildPrompt) => {
89
+ const run = async () => {
90
+ lastHaikuCallAt = Date.now();
91
+ const isFirst = !haikuInitialized;
92
+ const prompt = buildPrompt({ isFirst });
93
+ const raw = await callHaiku(prompt, { isFirst });
94
+ // Only flip to initialised after a successful call so a failed first call is retried
95
+ // (still as first) rather than leaving Haiku with no catalog/rules in its context.
96
+ haikuInitialized = true;
97
+ return raw;
98
+ };
99
+ // Chain onto the previous call; whether it resolved or rejected, we run next.
100
+ const next = haikuChain.then(run, run);
101
+ haikuChain = next.catch(() => {}); // swallow so chain survives rejections
102
+ return next;
103
+ };
104
+
47
105
  const handler = async (envelope) => {
48
106
  if (!envelope || typeof envelope !== 'object') {
49
107
  const err = new Error('invalid envelope');
@@ -55,6 +113,16 @@ export async function startDaemon({
55
113
  err.code = 'E_INTERNAL';
56
114
  throw err;
57
115
  }
116
+
117
+ // 10-second window safety net: events that would invoke Haiku within 10s of our own
118
+ // claude -p spawn are likely recursive noise; pass them quietly.
119
+ const needsHaiku = envelope.event === 'user_input' || envelope.event === 'turn_end';
120
+ const sinceLast = Date.now() - lastHaikuCallAt;
121
+ if (needsHaiku && lastHaikuCallAt > 0 && sinceLast < HAIKU_CALL_WINDOW_MS) {
122
+ logFn(`${envelope.event} skipped: within ${sinceLast}ms of own haiku call`);
123
+ return { pass: true, missing_tools: [], reason: 'within_haiku_call_window' };
124
+ }
125
+
58
126
  switch (envelope.event) {
59
127
  case 'readiness':
60
128
  return { ready: true };
@@ -85,8 +153,9 @@ export async function startDaemon({
85
153
  state.lastUserInput = userInput;
86
154
  state.usedTools = []; // reset tools for this turn
87
155
 
88
- const prompt = buildFirstStagePrompt({ catalog, userInput });
89
- const raw = await callHaiku(prompt);
156
+ const raw = await callHaikuTracked(({ isFirst }) =>
157
+ buildFirstStagePrompt({ catalog, userInput, isFirst })
158
+ );
90
159
  const parsed = parseHaikuResponse(raw);
91
160
  logFn(`user_input: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
92
161
  return parsed;
@@ -112,25 +181,27 @@ export async function startDaemon({
112
181
  throw err;
113
182
  }
114
183
  if (payload.stop_hook_active === true) {
115
- // Spotter already intervened this turn — §7.5/§8.1 max-1-loop guarantee.
116
184
  logFn('turn_end: stop_hook_active=true, passing');
117
185
  state.usedTools = [];
118
186
  state.lastUserInput = null;
119
187
  return { pass: true, missing_tools: [], reason: 'stop_hook_active' };
120
188
  }
121
189
  if (state.lastUserInput === null) {
122
- // No user_input seen this turn — nothing to audit against. Pass quietly.
123
190
  logFn('turn_end: no user_input observed, passing');
124
191
  return { pass: true, missing_tools: [], reason: 'no_user_input' };
125
192
  }
126
193
 
127
- const prompt = buildFinalStagePrompt({
128
- catalog,
129
- userInput: state.lastUserInput,
130
- usedTools: state.usedTools,
131
- finalResponse,
132
- });
133
- const raw = await callHaiku(prompt);
194
+ const savedUserInput = state.lastUserInput;
195
+ const savedUsedTools = state.usedTools.slice();
196
+ const raw = await callHaikuTracked(({ isFirst }) =>
197
+ buildFinalStagePrompt({
198
+ catalog,
199
+ userInput: savedUserInput,
200
+ usedTools: savedUsedTools,
201
+ finalResponse,
202
+ isFirst,
203
+ })
204
+ );
134
205
  const parsed = parseHaikuResponse(raw);
135
206
  logFn(`turn_end: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
136
207
 
@@ -149,7 +220,7 @@ export async function startDaemon({
149
220
  await new Promise((resolve, reject) => {
150
221
  server.on('error', (err) => reject(err));
151
222
  server.listen(path, () => {
152
- logFn(`daemon listening on ${path}`);
223
+ logFn(`daemon listening on ${path} (haikuSessionId=${ownHaikuSessionId})`);
153
224
  resolve();
154
225
  });
155
226
  });
@@ -162,18 +233,41 @@ export async function startDaemon({
162
233
  server,
163
234
  path,
164
235
  pidPath,
236
+ haikuSessionId: ownHaikuSessionId,
165
237
  stop: () => shutdown(server, sessionId, logFn),
166
238
  };
167
239
  }
168
240
 
241
+ async function assertNoLiveDaemon(sessionId) {
242
+ const pidPath = pidFilePath(sessionId);
243
+ let raw;
244
+ try {
245
+ raw = await readFile(pidPath, 'utf8');
246
+ } catch (err) {
247
+ if (err.code === 'ENOENT') return;
248
+ throw err;
249
+ }
250
+ const pid = parseInt(raw.trim(), 10);
251
+ if (!Number.isFinite(pid)) return; // stale/malformed — treat as absent
252
+ try {
253
+ process.kill(pid, 0);
254
+ } catch (err) {
255
+ if (err.code === 'ESRCH') return; // process gone; stale PID file is fine
256
+ if (err.code === 'EPERM') {
257
+ // running under another user — still counts as live
258
+ throw new DaemonAlreadyRunningError(sessionId, pid);
259
+ }
260
+ return;
261
+ }
262
+ throw new DaemonAlreadyRunningError(sessionId, pid);
263
+ }
264
+
169
265
  async function shutdown(server, sessionId, logFn) {
170
266
  try {
171
267
  await new Promise((resolve) => server.close(resolve));
172
268
  } catch (err) {
173
- // SessionEnd cleanup failures are §14.1 exceptions — warn only.
174
269
  logFn(`shutdown: server.close failed: ${err.message}`);
175
270
  }
176
- // On Unix, remove the socket file. On Windows, Named Pipes are auto-cleaned.
177
271
  if (process.platform !== 'win32') {
178
272
  try {
179
273
  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,
@@ -154,8 +181,14 @@ function truncate(s, n = 300) {
154
181
  // On Windows, the `claude` entry is typically a .cmd shim which Node's spawn
155
182
  // cannot locate without going through the shell. We use cmd.exe /c explicitly
156
183
  // rather than spawn({ shell: true }) because the latter triggers DEP0190 on Node 24+.
157
- function buildSpawnArgs(claudeBin, model) {
158
- const args = ['-p', '--model', model];
184
+ //
185
+ // v0.2: For the first call of a daemon's lifetime, spawn with `--session-id <haikuSessionId>`
186
+ // to create a new Haiku conversation. For subsequent calls, spawn with `--resume <haikuSessionId>`
187
+ // to continue that same conversation (so catalog/system rules persist in Haiku's context).
188
+ // Note: `--bare` was tried but fails with "Not logged in" — it is intentionally NOT used.
189
+ function buildSpawnArgs(claudeBin, model, haikuSessionId, isFirstCall) {
190
+ const sessionFlag = isFirstCall ? '--session-id' : '--resume';
191
+ const args = ['-p', sessionFlag, haikuSessionId, '--model', model];
159
192
  if (process.platform === 'win32') {
160
193
  return { cmd: 'cmd.exe', cmdArgs: ['/c', claudeBin, ...args] };
161
194
  }
@@ -164,18 +197,25 @@ function buildSpawnArgs(claudeBin, model) {
164
197
 
165
198
  // Invoke `claude -p` in the isolated workdir. Returns raw stdout.
166
199
  // §5.5: no retry on failure. §14.1: silent fallback forbidden.
167
- export function createHaikuCaller({ timeoutMs, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
200
+ //
201
+ // v0.2: `haikuSessionId` is required — used for --session-id (first call) / --resume (subsequent).
202
+ // The SPOTTER_PARENT_PID env var is always injected so hooks firing inside the spawned claude
203
+ // exit early via isChildCall() (prevents daemon-spawn recursion).
204
+ export function createHaikuCaller({ timeoutMs, haikuSessionId, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
168
205
  if (typeof timeoutMs !== 'number' || timeoutMs <= 0) {
169
206
  throw new TypeError('timeoutMs must be a positive number');
170
207
  }
208
+ if (typeof haikuSessionId !== 'string' || haikuSessionId.length === 0) {
209
+ throw new TypeError('haikuSessionId is required (non-empty string)');
210
+ }
171
211
 
172
- return async function callHaiku(prompt) {
212
+ return async function callHaiku(prompt, { isFirst = true } = {}) {
173
213
  await ensureWorkdir();
174
214
  return new Promise((resolve, reject) => {
175
- const { cmd, cmdArgs } = buildSpawnArgs(claudeBin, model);
215
+ const { cmd, cmdArgs } = buildSpawnArgs(claudeBin, model, haikuSessionId, isFirst);
176
216
  const child = spawn(cmd, cmdArgs, {
177
217
  cwd: WORKDIR,
178
- env,
218
+ env: { ...env, SPOTTER_PARENT_PID: String(process.pid) },
179
219
  stdio: ['pipe', 'pipe', 'pipe'],
180
220
  windowsHide: true,
181
221
  });
package/src/hooks/lib.mjs CHANGED
@@ -6,6 +6,24 @@
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
+ export function isChildCall() {
17
+ const v = process.env.SPOTTER_PARENT_PID;
18
+ return typeof v === 'string' && v.length > 0;
19
+ }
20
+
21
+ export function isSubagentCall(input) {
22
+ return input !== null
23
+ && typeof input === 'object'
24
+ && typeof input.agent_id === 'string'
25
+ && input.agent_id.length > 0;
26
+ }
9
27
 
10
28
  export async function readStdinJson() {
11
29
  let raw = '';
@@ -1,12 +1,16 @@
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 } 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
+
10
14
  const sessionId = requireString(input, 'session_id');
11
15
  const toolName = requireString(input, 'tool_name');
12
16
 
@@ -1,12 +1,16 @@
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 } 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
+
10
14
  const sessionId = requireString(input, 'session_id');
11
15
 
12
16
  try {
@@ -1,19 +1,35 @@
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)
4
10
 
5
11
  import { spawn } from 'node:child_process';
6
12
  import { setTimeout as delay } from 'node:timers/promises';
7
13
  import { fileURLToPath } from 'node:url';
8
14
  import { dirname, resolve } from 'node:path';
9
- import { readStdinJson, requireString, die } from './lib.mjs';
15
+ import { readStdinJson, requireString, die, isChildCall, isSubagentCall } from './lib.mjs';
10
16
  import { sendRequest, TransportError } from '../daemon/transport.mjs';
11
17
 
12
18
  const READINESS_TIMEOUT_MS = 3_000;
13
19
  const POLL_INTERVAL_MS = 100;
14
20
 
15
21
  export async function runSessionStart({ argv = process.argv, now = Date.now } = {}) {
22
+ // Gate 1 (pre-stdin): Spotter's own claude -p subprocess — exit without reading stdin.
23
+ if (isChildCall()) return;
24
+
16
25
  const input = await readStdinJson();
26
+
27
+ // Gate 2: Task subagent — skip audit.
28
+ if (isSubagentCall(input)) return;
29
+
30
+ // Gate 3: non-startup sources (resume/compact/clear) don't spawn a new daemon.
31
+ if (input.source !== 'startup') return;
32
+
17
33
  const sessionId = requireString(input, 'session_id');
18
34
 
19
35
  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,18 @@ import {
8
9
  exitCodeFor,
9
10
  die,
10
11
  formatTransparentBlockReason,
12
+ isChildCall,
13
+ isSubagentCall,
11
14
  } from './lib.mjs';
12
15
  import { sendRequest } from '../daemon/transport.mjs';
13
16
 
14
17
  const TIMEOUT_MS = 15_000;
15
18
 
16
19
  export async function runStop() {
20
+ if (isChildCall()) return;
17
21
  const input = await readStdinJson();
22
+ if (isSubagentCall(input)) return;
23
+
18
24
  const sessionId = requireString(input, 'session_id');
19
25
  const stopHookActive = input.stop_hook_active === true;
20
26
  // Claude Code passes the transcript path; the final response is read from there or provided inline.
@@ -1,12 +1,24 @@
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
+ } from './lib.mjs';
4
13
  import { sendRequest } from '../daemon/transport.mjs';
5
14
 
6
15
  const TIMEOUT_MS = 30_000;
7
16
 
8
17
  export async function runUserPrompt() {
18
+ if (isChildCall()) return;
9
19
  const input = await readStdinJson();
20
+ if (isSubagentCall(input)) return;
21
+
10
22
  const sessionId = requireString(input, 'session_id');
11
23
  const prompt = requireString(input, 'prompt');
12
24
 
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const version = '0.1.0';
1
+ export const version = '0.2.0';