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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/bin/spotter.mjs +104 -0
- package/package.json +48 -0
- package/src/catalog/lint.mjs +56 -0
- package/src/catalog/loader.mjs +36 -0
- package/src/catalog/schema.mjs +109 -0
- package/src/cli/catalog.mjs +37 -0
- package/src/cli/daemon-cmd.mjs +41 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/install.mjs +126 -0
- package/src/cli/status.mjs +62 -0
- package/src/cli/uninstall.mjs +75 -0
- package/src/daemon/daemon.mjs +198 -0
- package/src/daemon/haiku-caller.mjs +217 -0
- package/src/daemon/transport.mjs +142 -0
- package/src/hooks/lib.mjs +88 -0
- package/src/hooks/pre-tool-use.mjs +30 -0
- package/src/hooks/session-end.mjs +30 -0
- package/src/hooks/session-start.mjs +69 -0
- package/src/hooks/stop.mjs +56 -0
- package/src/hooks/user-prompt.mjs +48 -0
- package/src/index.mjs +19 -0
- package/src/version.mjs +1 -0
- package/templates/tools.yaml +111 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// hook ⇄ daemon transport (§5.6 socket abstraction, §5.7 envelope).
|
|
2
|
+
// Cross-platform: Unix domain socket on macOS/Linux, Named Pipe on Windows.
|
|
3
|
+
// Wire format: newline-delimited JSON, one request or response per line.
|
|
4
|
+
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { mkdir } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const RUNTIME_DIR = join(homedir(), '.spotter', 'runtime');
|
|
12
|
+
|
|
13
|
+
export function socketPath(sessionId) {
|
|
14
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
15
|
+
throw new TypeError('sessionId must be a non-empty string');
|
|
16
|
+
}
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
return `\\\\.\\pipe\\spotter-${sessionId}`;
|
|
19
|
+
}
|
|
20
|
+
return join(RUNTIME_DIR, `session-${sessionId}.sock`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function ensureRuntimeDir() {
|
|
24
|
+
await mkdir(RUNTIME_DIR, { recursive: true });
|
|
25
|
+
return RUNTIME_DIR;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Request/response over a single connection. Used by hooks.
|
|
29
|
+
export function sendRequest({ sessionId, event, payload, timeoutMs }) {
|
|
30
|
+
if (typeof timeoutMs !== 'number' || timeoutMs <= 0) {
|
|
31
|
+
throw new TypeError('timeoutMs must be a positive number');
|
|
32
|
+
}
|
|
33
|
+
const envelope = {
|
|
34
|
+
id: randomUUID(),
|
|
35
|
+
event,
|
|
36
|
+
session_id: sessionId,
|
|
37
|
+
payload: payload ?? {},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const path = socketPath(sessionId);
|
|
42
|
+
const sock = net.createConnection(path);
|
|
43
|
+
let buf = '';
|
|
44
|
+
let settled = false;
|
|
45
|
+
|
|
46
|
+
const settle = (fn, value) => {
|
|
47
|
+
if (settled) return;
|
|
48
|
+
settled = true;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
sock.destroy();
|
|
51
|
+
fn(value);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
settle(reject, new TransportError('E_TIMEOUT', `daemon did not respond within ${timeoutMs}ms`));
|
|
56
|
+
}, timeoutMs);
|
|
57
|
+
|
|
58
|
+
sock.on('connect', () => {
|
|
59
|
+
sock.write(JSON.stringify(envelope) + '\n');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
sock.on('data', (chunk) => {
|
|
63
|
+
buf += chunk.toString('utf8');
|
|
64
|
+
const newlineIdx = buf.indexOf('\n');
|
|
65
|
+
if (newlineIdx === -1) return;
|
|
66
|
+
const line = buf.slice(0, newlineIdx);
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(line);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
settle(reject, new TransportError('E_INTERNAL', `invalid JSON from daemon: ${err.message}`));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (parsed.id !== envelope.id) {
|
|
75
|
+
settle(reject, new TransportError('E_INTERNAL', `id mismatch: sent ${envelope.id}, got ${parsed.id}`));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
settle(resolve, parsed);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
sock.on('error', (err) => {
|
|
82
|
+
if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
|
|
83
|
+
settle(reject, new TransportError('E_UNREACHABLE', `daemon unreachable at ${path}: ${err.code}`));
|
|
84
|
+
} else {
|
|
85
|
+
settle(reject, new TransportError('E_INTERNAL', `socket error: ${err.message}`));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class TransportError extends Error {
|
|
92
|
+
constructor(code, message) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = 'TransportError';
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Daemon side: listen on socket, dispatch envelopes through a handler.
|
|
100
|
+
// handler: async (envelope) => result — result is put into { ok: true, result } or
|
|
101
|
+
// on throw packed into { ok: false, error: { code, message } }.
|
|
102
|
+
export function createServer({ sessionId, handler, onError }) {
|
|
103
|
+
const path = socketPath(sessionId);
|
|
104
|
+
const server = net.createServer((conn) => {
|
|
105
|
+
let buf = '';
|
|
106
|
+
conn.on('data', async (chunk) => {
|
|
107
|
+
buf += chunk.toString('utf8');
|
|
108
|
+
let newlineIdx;
|
|
109
|
+
while ((newlineIdx = buf.indexOf('\n')) !== -1) {
|
|
110
|
+
const line = buf.slice(0, newlineIdx);
|
|
111
|
+
buf = buf.slice(newlineIdx + 1);
|
|
112
|
+
if (line.length === 0) continue;
|
|
113
|
+
let envelope;
|
|
114
|
+
try {
|
|
115
|
+
envelope = JSON.parse(line);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Malformed client input — write minimal error response with null id.
|
|
118
|
+
conn.write(JSON.stringify({
|
|
119
|
+
id: null,
|
|
120
|
+
ok: false,
|
|
121
|
+
error: { code: 'E_INTERNAL', message: `invalid JSON: ${err.message}` },
|
|
122
|
+
}) + '\n');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const result = await handler(envelope);
|
|
127
|
+
conn.write(JSON.stringify({ id: envelope.id, ok: true, result }) + '\n');
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const code = err.code && typeof err.code === 'string' ? err.code : 'E_INTERNAL';
|
|
130
|
+
const message = err.message ?? String(err);
|
|
131
|
+
conn.write(JSON.stringify({ id: envelope.id, ok: false, error: { code, message } }) + '\n');
|
|
132
|
+
if (onError) onError(err, envelope);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
conn.on('error', (err) => {
|
|
137
|
+
if (onError) onError(err, null);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return { server, path };
|
|
142
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Shared hook plumbing — stdin read, error → exit-code mapping per §14.3 / §14.4.
|
|
2
|
+
//
|
|
3
|
+
// Exit code contract (§14.3 / §14.4):
|
|
4
|
+
// 0 = success (normal flow)
|
|
5
|
+
// 1 = expected abnormal (daemon unreachable; user should restart daemon)
|
|
6
|
+
// 2 = unexpected (propagate to Claude Code transcript)
|
|
7
|
+
//
|
|
8
|
+
// Silent fallback (exit 0 with missing behaviour) is forbidden. See §14.1.
|
|
9
|
+
|
|
10
|
+
export async function readStdinJson() {
|
|
11
|
+
let raw = '';
|
|
12
|
+
process.stdin.setEncoding('utf8');
|
|
13
|
+
for await (const chunk of process.stdin) {
|
|
14
|
+
raw += chunk;
|
|
15
|
+
}
|
|
16
|
+
if (raw.length === 0) {
|
|
17
|
+
const err = new Error('hook received empty stdin — Claude Code is expected to provide a JSON envelope');
|
|
18
|
+
err.exitCode = 2;
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
const err = new Error(`hook stdin is not valid JSON: ${cause.message}`);
|
|
25
|
+
err.exitCode = 2;
|
|
26
|
+
err.cause = cause;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function requireString(input, key) {
|
|
32
|
+
const value = input[key];
|
|
33
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
34
|
+
const err = new Error(`hook input missing required string "${key}"`);
|
|
35
|
+
err.exitCode = 2;
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function optionalString(input, key) {
|
|
42
|
+
const value = input[key];
|
|
43
|
+
if (value === undefined || value === null) return null;
|
|
44
|
+
if (typeof value !== 'string') {
|
|
45
|
+
const err = new Error(`hook input "${key}" must be a string if present`);
|
|
46
|
+
err.exitCode = 2;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Map a TransportError / HaikuError into an exit code.
|
|
53
|
+
export function exitCodeFor(err) {
|
|
54
|
+
if (err && typeof err.code === 'string') {
|
|
55
|
+
if (err.code === 'E_UNREACHABLE') return 1;
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function die(message, exitCode = 2) {
|
|
62
|
+
process.stderr.write(`spotter-hook: ${message}\n`);
|
|
63
|
+
process.exit(exitCode);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatTransparentContext(missingTools) {
|
|
67
|
+
// §12.2: transparent phrasing — Bell should reference Spotter explicitly.
|
|
68
|
+
const lines = missingTools.map((m) => `- \`${m.name}\`: ${m.reason}`);
|
|
69
|
+
return [
|
|
70
|
+
'[Spotter からの推奨ツール]',
|
|
71
|
+
'このプロンプトに応答する前に、以下のツールを使うべきか検討してください。',
|
|
72
|
+
...lines,
|
|
73
|
+
'',
|
|
74
|
+
'使う場合は「Spotter の推奨に従い〜」のように監査役の指摘を明示してください。',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatTransparentBlockReason(missingTools) {
|
|
79
|
+
// §12.3: transparent phrasing for Stop hook block.
|
|
80
|
+
const lines = missingTools.map((m) => `- \`${m.name}\`: ${m.reason}`);
|
|
81
|
+
return [
|
|
82
|
+
'[Spotter からの指摘]',
|
|
83
|
+
'上記応答ではツールが不足している可能性があります。以下を検討し、必要なら呼び出した上で応答を補正してください。',
|
|
84
|
+
...lines,
|
|
85
|
+
'',
|
|
86
|
+
'応答には「Spotter からの指摘を受けて〜」のように監査役の介入を明示してください。',
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// PreToolUse hook — record tool usage in daemon (lightweight, no Haiku call). §9.1 v0.1.
|
|
2
|
+
|
|
3
|
+
import { readStdinJson, requireString, exitCodeFor, die } from './lib.mjs';
|
|
4
|
+
import { sendRequest } from '../daemon/transport.mjs';
|
|
5
|
+
|
|
6
|
+
const TIMEOUT_MS = 1_000;
|
|
7
|
+
|
|
8
|
+
export async function runPreToolUse() {
|
|
9
|
+
const input = await readStdinJson();
|
|
10
|
+
const sessionId = requireString(input, 'session_id');
|
|
11
|
+
const toolName = requireString(input, 'tool_name');
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const response = await sendRequest({
|
|
15
|
+
sessionId,
|
|
16
|
+
event: 'tool_used',
|
|
17
|
+
payload: { tool_name: toolName },
|
|
18
|
+
timeoutMs: TIMEOUT_MS,
|
|
19
|
+
});
|
|
20
|
+
if (response.ok !== true) {
|
|
21
|
+
die(`daemon error on tool_used: ${response.error?.code ?? '?'}: ${response.error?.message ?? ''}`, 2);
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {
|
|
24
|
+
die(`pre-tool-use transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
29
|
+
runPreToolUse().catch((err) => die(err.message, err.exitCode ?? 2));
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SessionEnd hook — best-effort shutdown notice. §14.1 exception: cleanup failures warn only.
|
|
2
|
+
|
|
3
|
+
import { readStdinJson, requireString } from './lib.mjs';
|
|
4
|
+
import { sendRequest } from '../daemon/transport.mjs';
|
|
5
|
+
|
|
6
|
+
const TIMEOUT_MS = 2_000;
|
|
7
|
+
|
|
8
|
+
export async function runSessionEnd() {
|
|
9
|
+
const input = await readStdinJson();
|
|
10
|
+
const sessionId = requireString(input, 'session_id');
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await sendRequest({
|
|
14
|
+
sessionId,
|
|
15
|
+
event: 'shutdown',
|
|
16
|
+
timeoutMs: TIMEOUT_MS,
|
|
17
|
+
});
|
|
18
|
+
} catch (err) {
|
|
19
|
+
// §14.1 exception — don't fail the Claude Code session just because cleanup failed.
|
|
20
|
+
process.stderr.write(`spotter-hook: session-end shutdown warning: ${err.code ?? '?'}: ${err.message}\n`);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
26
|
+
runSessionEnd().catch((err) => {
|
|
27
|
+
process.stderr.write(`spotter-hook: session-end unexpected error: ${err.message}\n`);
|
|
28
|
+
process.exit(0); // still don't block session end (§14.1 exception)
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SessionStart hook — spawn daemon detached, wait up to 3s for readiness (§9.1).
|
|
2
|
+
//
|
|
3
|
+
// §14.3 classifies readiness failure as unexpected (exit 2). §14.1 forbids silent fallback.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { dirname, resolve } from 'node:path';
|
|
9
|
+
import { readStdinJson, requireString, die } from './lib.mjs';
|
|
10
|
+
import { sendRequest, TransportError } from '../daemon/transport.mjs';
|
|
11
|
+
|
|
12
|
+
const READINESS_TIMEOUT_MS = 3_000;
|
|
13
|
+
const POLL_INTERVAL_MS = 100;
|
|
14
|
+
|
|
15
|
+
export async function runSessionStart({ argv = process.argv, now = Date.now } = {}) {
|
|
16
|
+
const input = await readStdinJson();
|
|
17
|
+
const sessionId = requireString(input, 'session_id');
|
|
18
|
+
|
|
19
|
+
spawnDaemon(sessionId, argv);
|
|
20
|
+
|
|
21
|
+
const deadline = now() + READINESS_TIMEOUT_MS;
|
|
22
|
+
while (now() < deadline) {
|
|
23
|
+
try {
|
|
24
|
+
const resp = await sendRequest({
|
|
25
|
+
sessionId,
|
|
26
|
+
event: 'readiness',
|
|
27
|
+
timeoutMs: 500,
|
|
28
|
+
});
|
|
29
|
+
if (resp.ok === true && resp.result && resp.result.ready === true) {
|
|
30
|
+
return; // success — exit 0 implicitly
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (!(err instanceof TransportError) || err.code !== 'E_UNREACHABLE') {
|
|
34
|
+
// E_TIMEOUT or internal errors while daemon is booting — keep polling.
|
|
35
|
+
// E_UNREACHABLE means the socket file/pipe doesn't exist yet — also retryable.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
await delay(POLL_INTERVAL_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
die(`daemon did not reach readiness within ${READINESS_TIMEOUT_MS}ms for session ${sessionId}`, 2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function spawnDaemon(sessionId, argv) {
|
|
45
|
+
// Invoke `node <spotter-bin> daemon start --session-id ...` detached.
|
|
46
|
+
const spotterBin = resolveSpotterBin(argv);
|
|
47
|
+
const child = spawn(process.execPath, [spotterBin, 'daemon', 'start', '--session-id', sessionId], {
|
|
48
|
+
detached: true,
|
|
49
|
+
stdio: 'ignore',
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
});
|
|
52
|
+
child.on('error', (err) => {
|
|
53
|
+
// best effort: the polling below will fail if the spawn actually didn't work
|
|
54
|
+
process.stderr.write(`spotter-hook: daemon spawn error: ${err.message}\n`);
|
|
55
|
+
});
|
|
56
|
+
child.unref();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveSpotterBin(argv) {
|
|
60
|
+
// argv[1] is the path to the currently running script (bin/spotter.mjs when invoked via CLI,
|
|
61
|
+
// or src/hooks/session-start.mjs in tests). Walk up to the package root.
|
|
62
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
return resolve(here, '..', '..', 'bin', 'spotter.mjs');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Direct-execution entry — used when called as `node session-start.mjs`.
|
|
67
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
68
|
+
runSessionStart().catch((err) => die(err.message, err.exitCode ?? 2));
|
|
69
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Stop hook — send turn_end, return decision:"block" on miss (§12.3 transparent).
|
|
2
|
+
// `stop_hook_active: true` → daemon returns pass automatically (§7.5 max-1-loop).
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
readStdinJson,
|
|
6
|
+
requireString,
|
|
7
|
+
optionalString,
|
|
8
|
+
exitCodeFor,
|
|
9
|
+
die,
|
|
10
|
+
formatTransparentBlockReason,
|
|
11
|
+
} from './lib.mjs';
|
|
12
|
+
import { sendRequest } from '../daemon/transport.mjs';
|
|
13
|
+
|
|
14
|
+
const TIMEOUT_MS = 15_000;
|
|
15
|
+
|
|
16
|
+
export async function runStop() {
|
|
17
|
+
const input = await readStdinJson();
|
|
18
|
+
const sessionId = requireString(input, 'session_id');
|
|
19
|
+
const stopHookActive = input.stop_hook_active === true;
|
|
20
|
+
// Claude Code passes the transcript path; the final response is read from there or provided inline.
|
|
21
|
+
const finalResponse = optionalString(input, 'final_response') ?? '(no final response provided)';
|
|
22
|
+
|
|
23
|
+
let response;
|
|
24
|
+
try {
|
|
25
|
+
response = await sendRequest({
|
|
26
|
+
sessionId,
|
|
27
|
+
event: 'turn_end',
|
|
28
|
+
payload: {
|
|
29
|
+
final_response: finalResponse,
|
|
30
|
+
stop_hook_active: stopHookActive,
|
|
31
|
+
},
|
|
32
|
+
timeoutMs: TIMEOUT_MS,
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
die(`stop transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (response.ok !== true) {
|
|
40
|
+
die(`daemon error on turn_end: ${response.error?.code ?? '?'}: ${response.error?.message ?? ''}`, 2);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = response.result;
|
|
45
|
+
if (result.pass === true) {
|
|
46
|
+
return; // no block
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const reason = formatTransparentBlockReason(result.missing_tools);
|
|
50
|
+
const output = { decision: 'block', reason };
|
|
51
|
+
process.stdout.write(JSON.stringify(output));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
55
|
+
runStop().catch((err) => die(err.message, err.exitCode ?? 2));
|
|
56
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// UserPromptSubmit hook — send user_input to daemon, inject additionalContext (§12.2 transparent).
|
|
2
|
+
|
|
3
|
+
import { readStdinJson, requireString, exitCodeFor, die, formatTransparentContext } from './lib.mjs';
|
|
4
|
+
import { sendRequest } from '../daemon/transport.mjs';
|
|
5
|
+
|
|
6
|
+
const TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
export async function runUserPrompt() {
|
|
9
|
+
const input = await readStdinJson();
|
|
10
|
+
const sessionId = requireString(input, 'session_id');
|
|
11
|
+
const prompt = requireString(input, 'prompt');
|
|
12
|
+
|
|
13
|
+
let response;
|
|
14
|
+
try {
|
|
15
|
+
response = await sendRequest({
|
|
16
|
+
sessionId,
|
|
17
|
+
event: 'user_input',
|
|
18
|
+
payload: { user_input: prompt },
|
|
19
|
+
timeoutMs: TIMEOUT_MS,
|
|
20
|
+
});
|
|
21
|
+
} catch (err) {
|
|
22
|
+
die(`user-prompt transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (response.ok !== true) {
|
|
27
|
+
die(`daemon error on user_input: ${response.error?.code ?? '?'}: ${response.error?.message ?? ''}`, 2);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = response.result;
|
|
32
|
+
if (result.pass === true) {
|
|
33
|
+
return; // no additionalContext to inject
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const additionalContext = formatTransparentContext(result.missing_tools);
|
|
37
|
+
const output = {
|
|
38
|
+
hookSpecificOutput: {
|
|
39
|
+
hookEventName: 'UserPromptSubmit',
|
|
40
|
+
additionalContext,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
process.stdout.write(JSON.stringify(output));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
47
|
+
runUserPrompt().catch((err) => die(err.message, err.exitCode ?? 2));
|
|
48
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Public entry for programmatic use (e.g. `import { startDaemon } from 'claude-spotter'`).
|
|
2
|
+
|
|
3
|
+
export { startDaemon } from './daemon/daemon.mjs';
|
|
4
|
+
export {
|
|
5
|
+
sendRequest,
|
|
6
|
+
TransportError,
|
|
7
|
+
socketPath,
|
|
8
|
+
} from './daemon/transport.mjs';
|
|
9
|
+
export {
|
|
10
|
+
buildFirstStagePrompt,
|
|
11
|
+
buildFinalStagePrompt,
|
|
12
|
+
parseHaikuResponse,
|
|
13
|
+
createHaikuCaller,
|
|
14
|
+
HaikuError,
|
|
15
|
+
} from './daemon/haiku-caller.mjs';
|
|
16
|
+
export { loadCatalog, CatalogLoadError, CatalogSchemaError } from './catalog/loader.mjs';
|
|
17
|
+
export { validateCatalog } from './catalog/schema.mjs';
|
|
18
|
+
export { runLint } from './catalog/lint.mjs';
|
|
19
|
+
export { version } from './version.mjs';
|
package/src/version.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const version = '0.1.0';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
tools:
|
|
3
|
+
- name: current_time
|
|
4
|
+
category: time
|
|
5
|
+
purpose: >
|
|
6
|
+
現在の日時を正確に取得する。LLM は会話の流れから時刻を合成する癖があるが
|
|
7
|
+
不正確。時刻に言及する発話を出す前に必ず呼ぶ。
|
|
8
|
+
when_to_use:
|
|
9
|
+
- 「今何時」「今日は何日」等の時刻直接質問
|
|
10
|
+
- 「もう深夜だね」等の時刻言及を伴う発話
|
|
11
|
+
- 営業時間・締切・祝日など時間依存の判断
|
|
12
|
+
usage: current_time [timezone]
|
|
13
|
+
examples:
|
|
14
|
+
- input: もう夜遅いね
|
|
15
|
+
call: current_time Asia/Tokyo
|
|
16
|
+
- input: 今何時?
|
|
17
|
+
call: current_time
|
|
18
|
+
keywords: [今, 現在, 時刻, 日付, 何時, 今日, now, today, time]
|
|
19
|
+
test_cases:
|
|
20
|
+
- user_input: 今何時?
|
|
21
|
+
expected_tool: current_time
|
|
22
|
+
- user_input: 今日は何月何日?
|
|
23
|
+
expected_tool: current_time
|
|
24
|
+
|
|
25
|
+
- name: web_search
|
|
26
|
+
category: information
|
|
27
|
+
purpose: >
|
|
28
|
+
最新情報を Web から取得する。LLM の訓練データは特定時点までなので、
|
|
29
|
+
それ以降の出来事・価格・バージョン・ニュースは自力で答えない。
|
|
30
|
+
when_to_use:
|
|
31
|
+
- 最新ニュース・イベント・トレンドの質問
|
|
32
|
+
- 製品バージョン・リリース情報
|
|
33
|
+
- 価格・在庫・営業情報の問い合わせ
|
|
34
|
+
- 訓練データ以降に発生した事象の確認
|
|
35
|
+
usage: web_search <query>
|
|
36
|
+
examples:
|
|
37
|
+
- input: Claude の最新モデルは?
|
|
38
|
+
call: web_search "latest Claude model release"
|
|
39
|
+
- input: 今日の東京の天気
|
|
40
|
+
call: web_search "Tokyo weather today"
|
|
41
|
+
keywords: [最新, ニュース, 今, 現在, トレンド, 調べて, 検索, latest, news, search]
|
|
42
|
+
test_cases:
|
|
43
|
+
- user_input: React の最新バージョンは?
|
|
44
|
+
expected_tool: web_search
|
|
45
|
+
- user_input: 昨日のニュース教えて
|
|
46
|
+
expected_tool: web_search
|
|
47
|
+
|
|
48
|
+
- name: read_file
|
|
49
|
+
category: filesystem
|
|
50
|
+
purpose: >
|
|
51
|
+
ローカルファイルの内容を正確に読み取る。記憶から内容を再構成せず、
|
|
52
|
+
必ず最新の内容を取得する。
|
|
53
|
+
when_to_use:
|
|
54
|
+
- ユーザーがファイル名・パスを明示した質問
|
|
55
|
+
- コードのレビューや修正の依頼
|
|
56
|
+
- 設定ファイル・ログファイルの確認依頼
|
|
57
|
+
usage: read_file <path>
|
|
58
|
+
examples:
|
|
59
|
+
- input: package.json 見せて
|
|
60
|
+
call: read_file package.json
|
|
61
|
+
- input: src/index.js にバグある?
|
|
62
|
+
call: read_file src/index.js
|
|
63
|
+
keywords: [ファイル, 読ん, 開い, 見せて, 確認, read, file, open]
|
|
64
|
+
test_cases:
|
|
65
|
+
- user_input: README.md の内容は?
|
|
66
|
+
expected_tool: read_file
|
|
67
|
+
- user_input: この src/app.ts にバグがあるか確認して
|
|
68
|
+
expected_tool: read_file
|
|
69
|
+
|
|
70
|
+
- name: list_directory
|
|
71
|
+
category: filesystem
|
|
72
|
+
purpose: >
|
|
73
|
+
ディレクトリのファイル一覧を取得する。何があるか分からない状態で
|
|
74
|
+
推測せず、実際の一覧を確認する。
|
|
75
|
+
when_to_use:
|
|
76
|
+
- 「どんなファイルがある?」等のディレクトリ内容質問
|
|
77
|
+
- プロジェクト構造の把握
|
|
78
|
+
- 特定パターンのファイル存在確認
|
|
79
|
+
usage: list_directory <path>
|
|
80
|
+
examples:
|
|
81
|
+
- input: src/ 以下のファイル一覧
|
|
82
|
+
call: list_directory src/
|
|
83
|
+
- input: このプロジェクトの構造を教えて
|
|
84
|
+
call: list_directory .
|
|
85
|
+
keywords: [一覧, リスト, 構造, どんな, どこに, ls, list]
|
|
86
|
+
test_cases:
|
|
87
|
+
- user_input: test/ にどんなファイルある?
|
|
88
|
+
expected_tool: list_directory
|
|
89
|
+
|
|
90
|
+
- name: run_command
|
|
91
|
+
category: shell
|
|
92
|
+
purpose: >
|
|
93
|
+
シェルコマンドを実行する。ビルド・テスト・git 操作・環境確認など、
|
|
94
|
+
実行結果を知る必要がある場合に使う。実行結果を推測で答えない。
|
|
95
|
+
when_to_use:
|
|
96
|
+
- ビルド・テスト・リント実行の依頼
|
|
97
|
+
- git 操作 (status, log, diff)
|
|
98
|
+
- 環境確認 (node -v, which, 等)
|
|
99
|
+
- パッケージのインストール・更新
|
|
100
|
+
usage: run_command <command>
|
|
101
|
+
examples:
|
|
102
|
+
- input: テスト通る?
|
|
103
|
+
call: run_command "npm test"
|
|
104
|
+
- input: git の状態
|
|
105
|
+
call: run_command "git status"
|
|
106
|
+
keywords: [実行, 走ら, ビルド, テスト, npm, git, run, build, test]
|
|
107
|
+
test_cases:
|
|
108
|
+
- user_input: node のバージョン確認して
|
|
109
|
+
expected_tool: run_command
|
|
110
|
+
- user_input: npm test 通るか見て
|
|
111
|
+
expected_tool: run_command
|