claude-spotter 0.1.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.
@@ -0,0 +1,126 @@
1
+ // `spotter install` — create ~/.spotter/, place template catalog, register hooks in .claude/settings.json.
2
+ //
3
+ // Per plan §15.4, this shows a diff and asks for confirmation before touching settings.json.
4
+
5
+ import { mkdir, writeFile, readFile, access, copyFile } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { join, resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { createInterface } from 'node:readline/promises';
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ const PACKAGE_ROOT = resolve(HERE, '..', '..');
13
+ const TEMPLATE_CATALOG = join(PACKAGE_ROOT, 'templates', 'tools.yaml');
14
+ const SPOTTER_BIN = join(PACKAGE_ROOT, 'bin', 'spotter.mjs');
15
+
16
+ const SPOTTER_HOME = join(homedir(), '.spotter');
17
+ const CATALOG_DEST = join(SPOTTER_HOME, 'tool-catalog', 'tools.yaml');
18
+
19
+ const HOOK_EVENTS = [
20
+ { event: 'SessionStart', sub: 'session-start', timeout: 5 },
21
+ { event: 'UserPromptSubmit', sub: 'user-prompt', timeout: 30 },
22
+ { event: 'PreToolUse', sub: 'pre-tool-use', timeout: 2 },
23
+ { event: 'Stop', sub: 'stop', timeout: 15 },
24
+ { event: 'SessionEnd', sub: 'session-end', timeout: 3 },
25
+ ];
26
+
27
+ export async function runInstall({ target = 'project', autoYes = false, cwd = process.cwd() } = {}) {
28
+ const settingsPath = target === 'user'
29
+ ? join(homedir(), '.claude', 'settings.json')
30
+ : join(cwd, '.claude', 'settings.json');
31
+
32
+ console.log('spotter install');
33
+ console.log(` package: ${PACKAGE_ROOT}`);
34
+ console.log(` settings: ${settingsPath}`);
35
+
36
+ // 1. create directories
37
+ await mkdir(SPOTTER_HOME, { recursive: true });
38
+ await mkdir(join(SPOTTER_HOME, 'tool-catalog'), { recursive: true });
39
+ await mkdir(join(SPOTTER_HOME, 'runtime'), { recursive: true });
40
+ await mkdir(join(SPOTTER_HOME, 'workdir'), { recursive: true });
41
+ await mkdir(join(SPOTTER_HOME, 'logs'), { recursive: true });
42
+
43
+ // 2. place catalog template if missing
44
+ if (!(await exists(CATALOG_DEST))) {
45
+ await copyFile(TEMPLATE_CATALOG, CATALOG_DEST);
46
+ console.log(` wrote ${CATALOG_DEST}`);
47
+ } else {
48
+ console.log(` catalog already present at ${CATALOG_DEST} (not overwritten)`);
49
+ }
50
+
51
+ // 3. compute desired settings.json with hooks
52
+ const current = await loadSettings(settingsPath);
53
+ const updated = mergeHooks(current);
54
+ const diff = diffSettings(current, updated);
55
+
56
+ if (diff === null) {
57
+ console.log(' hooks already registered — nothing to change');
58
+ return;
59
+ }
60
+
61
+ console.log('\n--- proposed .claude/settings.json changes ---');
62
+ console.log(diff);
63
+ console.log('---------------------------------------------\n');
64
+
65
+ if (!autoYes) {
66
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
67
+ const answer = await rl.question('apply these changes? [y/N] ');
68
+ rl.close();
69
+ if (!/^y(es)?$/i.test(answer.trim())) {
70
+ console.log('aborted.');
71
+ return;
72
+ }
73
+ }
74
+
75
+ await mkdir(dirname(settingsPath), { recursive: true });
76
+ await writeFile(settingsPath, JSON.stringify(updated, null, 2) + '\n', 'utf8');
77
+ console.log(`wrote ${settingsPath}`);
78
+ console.log('\nnext: reload Claude Code (or open a new session) to activate Spotter.');
79
+ }
80
+
81
+ async function exists(path) {
82
+ try {
83
+ await access(path);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async function loadSettings(path) {
91
+ try {
92
+ const raw = await readFile(path, 'utf8');
93
+ return JSON.parse(raw);
94
+ } catch (err) {
95
+ if (err.code === 'ENOENT') return {};
96
+ throw err;
97
+ }
98
+ }
99
+
100
+ function mergeHooks(current) {
101
+ const next = structuredClone(current);
102
+ next.hooks = next.hooks ?? {};
103
+
104
+ for (const { event, sub, timeout } of HOOK_EVENTS) {
105
+ const command = `node "${SPOTTER_BIN}" hook ${sub}`;
106
+ const hookEntry = { type: 'command', command, timeout };
107
+ const groups = next.hooks[event] = next.hooks[event] ?? [];
108
+
109
+ // Dedup: skip if an identical spotter command is already present
110
+ const alreadyHas = groups.some((g) =>
111
+ Array.isArray(g.hooks) &&
112
+ g.hooks.some((h) => h?.type === 'command' && h?.command?.includes('spotter.mjs') && h?.command?.includes(`hook ${sub}`))
113
+ );
114
+ if (alreadyHas) continue;
115
+
116
+ groups.push({ hooks: [hookEntry] });
117
+ }
118
+ return next;
119
+ }
120
+
121
+ function diffSettings(current, updated) {
122
+ const a = JSON.stringify(current, null, 2);
123
+ const b = JSON.stringify(updated, null, 2);
124
+ if (a === b) return null;
125
+ return `BEFORE:\n${a}\n\nAFTER:\n${b}`;
126
+ }
@@ -0,0 +1,62 @@
1
+ // `spotter status` — show running daemons (by PID file) and their socket state.
2
+
3
+ import { readdir, readFile, access } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { sendRequest, TransportError } from '../daemon/transport.mjs';
7
+
8
+ const RUNTIME_DIR = join(homedir(), '.spotter', 'runtime');
9
+
10
+ export async function runStatus() {
11
+ console.log('spotter status');
12
+
13
+ let entries;
14
+ try {
15
+ entries = await readdir(RUNTIME_DIR);
16
+ } catch (err) {
17
+ if (err.code === 'ENOENT') {
18
+ console.log(' no runtime directory yet — run `spotter install` first');
19
+ return;
20
+ }
21
+ throw err;
22
+ }
23
+
24
+ const pidFiles = entries.filter((n) => n.endsWith('.pid'));
25
+ if (pidFiles.length === 0) {
26
+ console.log(' no daemons registered');
27
+ return;
28
+ }
29
+
30
+ for (const pidFile of pidFiles) {
31
+ const sessionId = pidFile.replace(/^session-/, '').replace(/\.pid$/, '');
32
+ const pid = await readPid(join(RUNTIME_DIR, pidFile));
33
+ const alive = isProcessAlive(pid);
34
+ let socketState = '?';
35
+ try {
36
+ const resp = await sendRequest({ sessionId, event: 'readiness', timeoutMs: 500 });
37
+ socketState = resp.ok === true ? 'ready' : 'error';
38
+ } catch (err) {
39
+ if (err instanceof TransportError) socketState = err.code;
40
+ else socketState = 'unknown';
41
+ }
42
+ console.log(` session=${sessionId} pid=${pid} process=${alive ? 'alive' : 'dead'} socket=${socketState}`);
43
+ }
44
+ }
45
+
46
+ async function readPid(path) {
47
+ try {
48
+ return parseInt((await readFile(path, 'utf8')).trim(), 10);
49
+ } catch {
50
+ return NaN;
51
+ }
52
+ }
53
+
54
+ function isProcessAlive(pid) {
55
+ if (!Number.isFinite(pid)) return false;
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch (err) {
60
+ return err.code === 'EPERM';
61
+ }
62
+ }
@@ -0,0 +1,75 @@
1
+ // `spotter uninstall` — remove hook entries that reference this spotter installation.
2
+ // Does NOT delete ~/.spotter/ (user data), just unregisters hooks.
3
+
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { createInterface } from 'node:readline/promises';
8
+
9
+ export async function runUninstall({ target = 'project', autoYes = false, cwd = process.cwd() } = {}) {
10
+ const settingsPath = target === 'user'
11
+ ? join(homedir(), '.claude', 'settings.json')
12
+ : join(cwd, '.claude', 'settings.json');
13
+
14
+ console.log(`spotter uninstall (settings: ${settingsPath})`);
15
+
16
+ let current;
17
+ try {
18
+ current = JSON.parse(await readFile(settingsPath, 'utf8'));
19
+ } catch (err) {
20
+ if (err.code === 'ENOENT') {
21
+ console.log(' no settings.json present — nothing to uninstall');
22
+ return;
23
+ }
24
+ throw err;
25
+ }
26
+
27
+ if (!current.hooks) {
28
+ console.log(' no hooks block present — nothing to uninstall');
29
+ return;
30
+ }
31
+
32
+ const updated = structuredClone(current);
33
+ let removed = 0;
34
+ for (const [event, groups] of Object.entries(updated.hooks)) {
35
+ if (!Array.isArray(groups)) continue;
36
+ const kept = [];
37
+ for (const g of groups) {
38
+ if (!Array.isArray(g?.hooks)) { kept.push(g); continue; }
39
+ const filtered = g.hooks.filter((h) => !(h?.type === 'command' && h?.command?.includes('spotter.mjs')));
40
+ if (filtered.length > 0) {
41
+ kept.push({ ...g, hooks: filtered });
42
+ } else {
43
+ removed += g.hooks.length;
44
+ }
45
+ }
46
+ if (kept.length > 0) {
47
+ updated.hooks[event] = kept;
48
+ } else {
49
+ delete updated.hooks[event];
50
+ }
51
+ }
52
+ if (Object.keys(updated.hooks).length === 0) {
53
+ delete updated.hooks;
54
+ }
55
+
56
+ if (JSON.stringify(current) === JSON.stringify(updated)) {
57
+ console.log(' no spotter hooks found — nothing to remove');
58
+ return;
59
+ }
60
+
61
+ console.log(` will remove ${removed} spotter hook entries`);
62
+ if (!autoYes) {
63
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
64
+ const answer = await rl.question('apply? [y/N] ');
65
+ rl.close();
66
+ if (!/^y(es)?$/i.test(answer.trim())) {
67
+ console.log('aborted.');
68
+ return;
69
+ }
70
+ }
71
+
72
+ await writeFile(settingsPath, JSON.stringify(updated, null, 2) + '\n', 'utf8');
73
+ console.log(`wrote ${settingsPath}`);
74
+ console.log('note: ~/.spotter/ (catalog, logs) was not removed. delete manually if no longer needed.');
75
+ }
@@ -0,0 +1,198 @@
1
+ // Session-scoped daemon — receives hook events, dispatches to handlers,
2
+ // calls Haiku on user_input / turn_end, keeps used_tools in process memory.
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.
7
+
8
+ import { createServer, ensureRuntimeDir, socketPath } from './transport.mjs';
9
+ import {
10
+ buildFirstStagePrompt,
11
+ buildFinalStagePrompt,
12
+ parseHaikuResponse,
13
+ createHaikuCaller,
14
+ } from './haiku-caller.mjs';
15
+ import { loadCatalog } from '../catalog/loader.mjs';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { writeFile, unlink } from 'node:fs/promises';
19
+
20
+ const DEFAULT_CATALOG_PATH = join(homedir(), '.spotter', 'tool-catalog', 'tools.yaml');
21
+
22
+ export async function startDaemon({
23
+ sessionId,
24
+ catalogPath = DEFAULT_CATALOG_PATH,
25
+ haikuCaller,
26
+ logFn = () => {},
27
+ } = {}) {
28
+ if (!sessionId) {
29
+ throw new TypeError('sessionId is required');
30
+ }
31
+
32
+ await ensureRuntimeDir();
33
+
34
+ // Load catalog up front — daemon cannot run without it (§14.1).
35
+ const catalog = await loadCatalog(catalogPath);
36
+ logFn(`catalog loaded: ${catalog.tools.length} tools from ${catalogPath}`);
37
+
38
+ // Default Haiku caller: timeout below the hook timeout for user_input/turn_end (§5.7).
39
+ const callHaiku = haikuCaller ?? createHaikuCaller({ timeoutMs: 28_000 });
40
+
41
+ // Per-turn state, reset on turn_end.
42
+ const state = {
43
+ usedTools: [],
44
+ lastUserInput: null,
45
+ };
46
+
47
+ const handler = async (envelope) => {
48
+ if (!envelope || typeof envelope !== 'object') {
49
+ const err = new Error('invalid envelope');
50
+ err.code = 'E_INTERNAL';
51
+ throw err;
52
+ }
53
+ if (envelope.session_id !== sessionId) {
54
+ const err = new Error(`session_id mismatch: daemon=${sessionId}, event=${envelope.session_id}`);
55
+ err.code = 'E_INTERNAL';
56
+ throw err;
57
+ }
58
+ switch (envelope.event) {
59
+ case 'readiness':
60
+ return { ready: true };
61
+ case 'user_input':
62
+ return handleUserInput(envelope.payload ?? {});
63
+ case 'tool_used':
64
+ return handleToolUsed(envelope.payload ?? {});
65
+ case 'turn_end':
66
+ return handleTurnEnd(envelope.payload ?? {});
67
+ case 'shutdown':
68
+ setImmediate(() => shutdown(server, sessionId, logFn));
69
+ return { stopping: true };
70
+ default: {
71
+ const err = new Error(`unknown event: ${envelope.event}`);
72
+ err.code = 'E_INTERNAL';
73
+ throw err;
74
+ }
75
+ }
76
+ };
77
+
78
+ async function handleUserInput(payload) {
79
+ const userInput = payload.user_input;
80
+ if (typeof userInput !== 'string') {
81
+ const err = new Error('user_input payload must include user_input string');
82
+ err.code = 'E_INTERNAL';
83
+ throw err;
84
+ }
85
+ state.lastUserInput = userInput;
86
+ state.usedTools = []; // reset tools for this turn
87
+
88
+ const prompt = buildFirstStagePrompt({ catalog, userInput });
89
+ const raw = await callHaiku(prompt);
90
+ const parsed = parseHaikuResponse(raw);
91
+ logFn(`user_input: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
92
+ return parsed;
93
+ }
94
+
95
+ function handleToolUsed(payload) {
96
+ const name = payload.tool_name;
97
+ if (typeof name !== 'string' || name.length === 0) {
98
+ const err = new Error('tool_used payload must include non-empty tool_name');
99
+ err.code = 'E_INTERNAL';
100
+ throw err;
101
+ }
102
+ state.usedTools.push(name);
103
+ logFn(`tool_used: ${name} (cumulative=${state.usedTools.length})`);
104
+ return { recorded: true };
105
+ }
106
+
107
+ async function handleTurnEnd(payload) {
108
+ const finalResponse = payload.final_response;
109
+ if (typeof finalResponse !== 'string') {
110
+ const err = new Error('turn_end payload must include final_response string');
111
+ err.code = 'E_INTERNAL';
112
+ throw err;
113
+ }
114
+ if (payload.stop_hook_active === true) {
115
+ // Spotter already intervened this turn — §7.5/§8.1 max-1-loop guarantee.
116
+ logFn('turn_end: stop_hook_active=true, passing');
117
+ state.usedTools = [];
118
+ state.lastUserInput = null;
119
+ return { pass: true, missing_tools: [], reason: 'stop_hook_active' };
120
+ }
121
+ if (state.lastUserInput === null) {
122
+ // No user_input seen this turn — nothing to audit against. Pass quietly.
123
+ logFn('turn_end: no user_input observed, passing');
124
+ return { pass: true, missing_tools: [], reason: 'no_user_input' };
125
+ }
126
+
127
+ const prompt = buildFinalStagePrompt({
128
+ catalog,
129
+ userInput: state.lastUserInput,
130
+ usedTools: state.usedTools,
131
+ finalResponse,
132
+ });
133
+ const raw = await callHaiku(prompt);
134
+ const parsed = parseHaikuResponse(raw);
135
+ logFn(`turn_end: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
136
+
137
+ state.usedTools = [];
138
+ state.lastUserInput = null;
139
+ return parsed;
140
+ }
141
+
142
+ const onErrorFn = (err, envelope) => {
143
+ const evt = envelope?.event ?? '(pre-parse)';
144
+ logFn(`handler error on ${evt}: ${err.code ?? 'E_INTERNAL'}: ${err.message}`);
145
+ };
146
+
147
+ const { server, path } = createServer({ sessionId, handler, onError: onErrorFn });
148
+
149
+ await new Promise((resolve, reject) => {
150
+ server.on('error', (err) => reject(err));
151
+ server.listen(path, () => {
152
+ logFn(`daemon listening on ${path}`);
153
+ resolve();
154
+ });
155
+ });
156
+
157
+ // Write PID file so uninstall/doctor can reason about liveness (§15.3 doctor).
158
+ const pidPath = pidFilePath(sessionId);
159
+ await writeFile(pidPath, String(process.pid), 'utf8');
160
+
161
+ return {
162
+ server,
163
+ path,
164
+ pidPath,
165
+ stop: () => shutdown(server, sessionId, logFn),
166
+ };
167
+ }
168
+
169
+ async function shutdown(server, sessionId, logFn) {
170
+ try {
171
+ await new Promise((resolve) => server.close(resolve));
172
+ } catch (err) {
173
+ // SessionEnd cleanup failures are §14.1 exceptions — warn only.
174
+ logFn(`shutdown: server.close failed: ${err.message}`);
175
+ }
176
+ // On Unix, remove the socket file. On Windows, Named Pipes are auto-cleaned.
177
+ if (process.platform !== 'win32') {
178
+ try {
179
+ await unlink(socketPath(sessionId));
180
+ } catch (err) {
181
+ if (err.code !== 'ENOENT') {
182
+ logFn(`shutdown: unlink socket failed: ${err.message}`);
183
+ }
184
+ }
185
+ }
186
+ try {
187
+ await unlink(pidFilePath(sessionId));
188
+ } catch (err) {
189
+ if (err.code !== 'ENOENT') {
190
+ logFn(`shutdown: unlink pid failed: ${err.message}`);
191
+ }
192
+ }
193
+ logFn('daemon stopped');
194
+ }
195
+
196
+ export function pidFilePath(sessionId) {
197
+ return join(homedir(), '.spotter', 'runtime', `session-${sessionId}.pid`);
198
+ }
@@ -0,0 +1,217 @@
1
+ // claude -p --model claude-haiku-4-5-* wrapper.
2
+ // §5.5: structured JSON I/O, no retries, schema violations throw.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { mkdir } from 'node:fs/promises';
8
+
9
+ const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
10
+ const WORKDIR = join(homedir(), '.spotter', 'workdir');
11
+
12
+ export class HaikuError extends Error {
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.name = 'HaikuError';
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ export async function ensureWorkdir() {
21
+ // §5.2: the workdir is isolated. No CLAUDE.md here.
22
+ await mkdir(WORKDIR, { recursive: true });
23
+ return WORKDIR;
24
+ }
25
+
26
+ // Build the first-stage prompt — projection of catalog purpose/when_to_use only.
27
+ export function buildFirstStagePrompt({ catalog, userInput }) {
28
+ const toolsProjection = catalog.tools.map((t) => ({
29
+ name: t.name,
30
+ purpose: t.purpose,
31
+ when_to_use: t.when_to_use,
32
+ }));
33
+ return [
34
+ systemRules(),
35
+ '## ツールカタログ',
36
+ JSON.stringify(toolsProjection, null, 2),
37
+ '',
38
+ '## ユーザー入力',
39
+ userInput,
40
+ '',
41
+ '## 判定',
42
+ 'ユーザーの入力内容から、上記カタログのうち「呼ぶべきだったのに Bell が呼び忘れるリスクのあるツール」を全て列挙してください。',
43
+ '該当するツールが 1 件もない場合は `pass: true` にしてください。',
44
+ '必ず指定スキーマの JSON オブジェクトのみを返してください。他のテキストは一切含めないでください。',
45
+ ].join('\n');
46
+ }
47
+
48
+ // Build the final-stage prompt — Stop hook, after Bell's response.
49
+ export function buildFinalStagePrompt({ catalog, userInput, usedTools, finalResponse }) {
50
+ const toolsProjection = catalog.tools.map((t) => ({
51
+ name: t.name,
52
+ purpose: t.purpose,
53
+ when_to_use: t.when_to_use,
54
+ }));
55
+ return [
56
+ systemRules(),
57
+ '## ツールカタログ',
58
+ JSON.stringify(toolsProjection, null, 2),
59
+ '',
60
+ '## ユーザー入力',
61
+ userInput,
62
+ '',
63
+ '## Bell が既に使用したツール',
64
+ usedTools.length > 0 ? usedTools.map((t) => `- ${t}`).join('\n') : '(なし)',
65
+ '',
66
+ '## Bell の最終応答',
67
+ finalResponse,
68
+ '',
69
+ '## 判定',
70
+ 'ユーザーの入力と Bell の最終応答を見て、呼ぶべきだったのに呼ばれていないツールを列挙してください。',
71
+ '「既に使用したツール」に含まれるものは除外してください (同じツールを二重に指摘しないため)。',
72
+ '該当するツールが 1 件もない場合は `pass: true` にしてください。',
73
+ '必ず指定スキーマの JSON オブジェクトのみを返してください。他のテキストは一切含めないでください。',
74
+ ].join('\n');
75
+ }
76
+
77
+ function systemRules() {
78
+ return [
79
+ 'あなたは Spotter — Claude (Bell) が呼び忘れているツールを検出する監査役です。',
80
+ '',
81
+ '## 出力スキーマ (厳守)',
82
+ '```json',
83
+ '{',
84
+ ' "pass": <boolean>,',
85
+ ' "missing_tools": [',
86
+ ' { "name": "<tool_name>", "reason": "<一文の日本語>" }',
87
+ ' ]',
88
+ '}',
89
+ '```',
90
+ '',
91
+ '- `pass: true` なら `missing_tools: []`',
92
+ '- `pass: false` なら `missing_tools` は 1 件以上、`name` はカタログに存在するツール名',
93
+ '- JSON オブジェクトのみ出力。説明文・前置き・```json``` フェンス禁止',
94
+ ].join('\n');
95
+ }
96
+
97
+ // Parse Haiku's response. Throws HaikuError on schema violation.
98
+ export function parseHaikuResponse(raw) {
99
+ const trimmed = raw.trim();
100
+ // Be tolerant of a surrounding code fence, which Haiku sometimes adds despite the prompt.
101
+ const unfenced = stripFence(trimmed);
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(unfenced);
105
+ } catch (err) {
106
+ throw new HaikuError('E_HAIKU_SCHEMA', `haiku output is not valid JSON: ${err.message} :: raw=${truncate(raw)}`);
107
+ }
108
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
109
+ throw new HaikuError('E_HAIKU_SCHEMA', `haiku output root is not an object :: ${truncate(raw)}`);
110
+ }
111
+ if (typeof parsed.pass !== 'boolean') {
112
+ throw new HaikuError('E_HAIKU_SCHEMA', `haiku "pass" must be boolean :: ${truncate(raw)}`);
113
+ }
114
+ if (!Array.isArray(parsed.missing_tools)) {
115
+ throw new HaikuError('E_HAIKU_SCHEMA', `haiku "missing_tools" must be array :: ${truncate(raw)}`);
116
+ }
117
+ parsed.missing_tools.forEach((m, i) => {
118
+ if (m === null || typeof m !== 'object' || Array.isArray(m)) {
119
+ throw new HaikuError('E_HAIKU_SCHEMA', `missing_tools[${i}] not an object`);
120
+ }
121
+ if (typeof m.name !== 'string' || m.name.length === 0) {
122
+ throw new HaikuError('E_HAIKU_SCHEMA', `missing_tools[${i}].name must be non-empty string`);
123
+ }
124
+ if (typeof m.reason !== 'string' || m.reason.length === 0) {
125
+ throw new HaikuError('E_HAIKU_SCHEMA', `missing_tools[${i}].reason must be non-empty string`);
126
+ }
127
+ });
128
+ // Cross-field: pass: true implies missing_tools empty.
129
+ if (parsed.pass === true && parsed.missing_tools.length > 0) {
130
+ throw new HaikuError(
131
+ 'E_HAIKU_SCHEMA',
132
+ `pass: true with non-empty missing_tools is inconsistent :: ${truncate(raw)}`
133
+ );
134
+ }
135
+ if (parsed.pass === false && parsed.missing_tools.length === 0) {
136
+ throw new HaikuError(
137
+ 'E_HAIKU_SCHEMA',
138
+ `pass: false with empty missing_tools is inconsistent :: ${truncate(raw)}`
139
+ );
140
+ }
141
+ return parsed;
142
+ }
143
+
144
+ function stripFence(text) {
145
+ const fenceMatch = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
146
+ return fenceMatch ? fenceMatch[1] : text;
147
+ }
148
+
149
+ function truncate(s, n = 300) {
150
+ if (s.length <= n) return s;
151
+ return s.slice(0, n) + '...';
152
+ }
153
+
154
+ // On Windows, the `claude` entry is typically a .cmd shim which Node's spawn
155
+ // cannot locate without going through the shell. We use cmd.exe /c explicitly
156
+ // rather than spawn({ shell: true }) because the latter triggers DEP0190 on Node 24+.
157
+ function buildSpawnArgs(claudeBin, model) {
158
+ const args = ['-p', '--model', model];
159
+ if (process.platform === 'win32') {
160
+ return { cmd: 'cmd.exe', cmdArgs: ['/c', claudeBin, ...args] };
161
+ }
162
+ return { cmd: claudeBin, cmdArgs: args };
163
+ }
164
+
165
+ // Invoke `claude -p` in the isolated workdir. Returns raw stdout.
166
+ // §5.5: no retry on failure. §14.1: silent fallback forbidden.
167
+ export function createHaikuCaller({ timeoutMs, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
168
+ if (typeof timeoutMs !== 'number' || timeoutMs <= 0) {
169
+ throw new TypeError('timeoutMs must be a positive number');
170
+ }
171
+
172
+ return async function callHaiku(prompt) {
173
+ await ensureWorkdir();
174
+ return new Promise((resolve, reject) => {
175
+ const { cmd, cmdArgs } = buildSpawnArgs(claudeBin, model);
176
+ const child = spawn(cmd, cmdArgs, {
177
+ cwd: WORKDIR,
178
+ env,
179
+ stdio: ['pipe', 'pipe', 'pipe'],
180
+ windowsHide: true,
181
+ });
182
+ let stdout = '';
183
+ let stderr = '';
184
+ let settled = false;
185
+
186
+ const timer = setTimeout(() => {
187
+ if (settled) return;
188
+ settled = true;
189
+ child.kill();
190
+ reject(new HaikuError('E_HAIKU_TIMEOUT', `haiku did not respond within ${timeoutMs}ms`));
191
+ }, timeoutMs);
192
+
193
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
194
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
195
+
196
+ child.on('error', (err) => {
197
+ if (settled) return;
198
+ settled = true;
199
+ clearTimeout(timer);
200
+ reject(new HaikuError('E_INTERNAL', `failed to spawn ${claudeBin}: ${err.message}`));
201
+ });
202
+
203
+ child.on('close', (code) => {
204
+ if (settled) return;
205
+ settled = true;
206
+ clearTimeout(timer);
207
+ if (code !== 0) {
208
+ reject(new HaikuError('E_INTERNAL', `haiku exited with code ${code}: ${truncate(stderr)}`));
209
+ return;
210
+ }
211
+ resolve(stdout);
212
+ });
213
+
214
+ child.stdin.end(prompt, 'utf8');
215
+ });
216
+ };
217
+ }