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 ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (Unreleased)
4
+
5
+ Initial release.
6
+
7
+ ### Features
8
+
9
+ - Session-scoped daemon that audits tool usage alongside Claude Code (Bell)
10
+ - 5 hooks wired: SessionStart / UserPromptSubmit / PreToolUse / Stop / SessionEnd
11
+ - YAML tool catalog with 2-stage context (purpose/when_to_use first, usage/examples after)
12
+ - Structured JSON I/O with Claude Haiku 4.5
13
+ - Cross-platform socket transport (Unix domain socket / Windows Named Pipe via Node `net`)
14
+ - `spotter install / uninstall / catalog edit / catalog lint / status / doctor` CLI
15
+
16
+ ### Dependencies
17
+
18
+ - `js-yaml` (4.x) — required for catalog parsing. Node has no built-in YAML support, and hand-rolling a parser adds subtle bugs that violate §14.2 "no shim code" discipline. This is the single exception to the zero-dependency goal (§15.2).
19
+
20
+ ### Design
21
+
22
+ All non-negotiable design decisions — including transparency vs invisibility, JSON I/O, socket abstraction, message envelope, SessionStart readiness — are documented in [docs/spotter-plan.md](docs/spotter-plan.md).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kitepon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # Spotter
2
+
3
+ **気づく役と実行する役を分離する。** Spotter は Claude Code の横で静かに並走し、Bell (主役の Claude) が**ツールを呼び忘れたとき**に指摘する監査役です。
4
+
5
+ > Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。現在時刻を推測で答える、web_search を呼ばずに古い情報で応答する、read_file を使わずにファイルの中身を推測する — 「分からないと自覚できない」から、ツールを取りに行けない。
6
+
7
+ Spotter は、ツールカタログを完全に把握した別エージェント (Claude Haiku 4.5) をセッション毎にプロセスとして常駐させ、Bell の発話予定と応答を並走監査します。見落としを検出すると、透明化された指摘として Bell に届け、補正応答を促します。
8
+
9
+ ## インストール
10
+
11
+ ```bash
12
+ npm install -g claude-spotter
13
+ spotter install # .claude/settings.json に hook を登録 (diff を見せて確認)
14
+ ```
15
+
16
+ 次回 Claude Code セッションから自動で有効になります。
17
+
18
+ ## 動作要件
19
+
20
+ - Node.js **22.5 以上**
21
+ - Claude Code **2.0 以上**
22
+ - Claude **Max プラン** (`claude -p` で Haiku を起動するため)
23
+
24
+ ## コンセプト
25
+
26
+ ```
27
+ User 発話
28
+
29
+ UserPromptSubmit hook → Spotter がカタログと発話を見て一次判定
30
+
31
+ Bell Thinking (Spotter の推奨を additionalContext で受け取る)
32
+
33
+ Bell 最終応答
34
+
35
+ Stop hook → Spotter が応答と使用済みツールを見て最終チェック
36
+
37
+ 見落としあれば差し戻し (max 1 回、Claude Code の stop_hook_active で自動担保)
38
+ ```
39
+
40
+ `~/.spotter/tool-catalog/tools.yaml` に監査対象のツール用途を記述します。`current_time` / `web_search` / `read_file` / `list_directory` / `run_command` の雛形付き。
41
+
42
+ ## Throughline との関係
43
+
44
+ [Throughline](https://github.com/kitepon-rgb/Throughline) と Spotter は同じ作者が作った、**哲学を共有する別プロダクト**です。
45
+
46
+ | | Throughline | Spotter |
47
+ |---|---|---|
48
+ | 思想 | 引き算 (要らないものを退避) | 足し算 (足りない動作に気づかせる) |
49
+ | 対象 | コンテキスト肥大化 | ツール取りこぼし |
50
+ | 仕組み | hook で記憶退避 | hook でサブエージェント並走 |
51
+
52
+ 両者に共通するのは **「主体 (Bell) に頼らない仕組み」**。併用できます。
53
+
54
+ ## よく使うコマンド
55
+
56
+ ```bash
57
+ spotter catalog edit # ツールカタログを $EDITOR で開く
58
+ spotter catalog lint # YAML 検証 + test_cases を Haiku 実呼びで検証
59
+ spotter status # 稼働中の daemon 一覧
60
+ spotter doctor # 環境診断 (Node / claude CLI / カタログ整合性)
61
+ spotter uninstall # hook 登録を解除 (~/.spotter は残す)
62
+ ```
63
+
64
+ ## 設計ドキュメント
65
+
66
+ 全ての設計判断 — 透明化 vs 不可視化、JSON I/O、socket 抽象、メッセージ契約、SessionStart の readiness 戦略、§0 実装規範 — は [docs/spotter-plan.md](docs/spotter-plan.md) に記載しています。**実装を変更する前に必ず参照してください。**
67
+
68
+ ## 既知の制約
69
+
70
+ - Stop hook は Bell の最初の応答が**出力された後**に発火するため、Spotter が Stop で差し戻した場合、ユーザーは「最初の応答 + 補正応答」の 2 連続を見ます (Claude Code の hook 仕様による制約)。UserPromptSubmit 段階での先回り検出を精度の軸にしています
71
+ - v0.1 は JSON スキーマ違反時にリトライせず即 throw します (§14.1 silent fallback 禁止の帰結)。遵守率が下振れた場合は v0.2 でリトライ戦略を追加します
72
+
73
+ ## ライセンス
74
+
75
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // spotter — CLI entry point.
3
+ // dispatches to src/cli/* and src/hooks/*.
4
+
5
+ import { runInstall } from '../src/cli/install.mjs';
6
+ import { runUninstall } from '../src/cli/uninstall.mjs';
7
+ import { runDoctor } from '../src/cli/doctor.mjs';
8
+ import { runStatus } from '../src/cli/status.mjs';
9
+ import { runCatalogEdit, runCatalogLint } from '../src/cli/catalog.mjs';
10
+ import { runDaemonStart } from '../src/cli/daemon-cmd.mjs';
11
+ import { runSessionStart } from '../src/hooks/session-start.mjs';
12
+ import { runUserPrompt } from '../src/hooks/user-prompt.mjs';
13
+ import { runPreToolUse } from '../src/hooks/pre-tool-use.mjs';
14
+ import { runStop } from '../src/hooks/stop.mjs';
15
+ import { runSessionEnd } from '../src/hooks/session-end.mjs';
16
+
17
+ const USAGE = `spotter — Claude Code tool-call auditor
18
+
19
+ Usage:
20
+ spotter install [--user] [-y] register hooks in .claude/settings.json
21
+ spotter uninstall [--user] [-y] remove spotter hooks
22
+ spotter catalog edit open tool catalog in $EDITOR
23
+ spotter catalog lint validate catalog + run test_cases (Haiku live call)
24
+ spotter status show running daemons
25
+ spotter doctor environment diagnostic
26
+ spotter daemon start --session-id ID (internal) run session daemon
27
+ spotter hook <event> (internal) hook dispatch
28
+ events: session-start | user-prompt |
29
+ pre-tool-use | stop | session-end
30
+ spotter --help | -h this message
31
+ spotter --version | -v version
32
+ `;
33
+
34
+ async function main() {
35
+ const argv = process.argv.slice(2);
36
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
37
+ process.stdout.write(USAGE);
38
+ return;
39
+ }
40
+ if (argv[0] === '--version' || argv[0] === '-v') {
41
+ const { version } = await import('../src/version.mjs');
42
+ process.stdout.write(`spotter ${version}\n`);
43
+ return;
44
+ }
45
+
46
+ const [cmd, ...rest] = argv;
47
+ switch (cmd) {
48
+ case 'install': {
49
+ const target = rest.includes('--user') ? 'user' : 'project';
50
+ const autoYes = rest.includes('-y') || rest.includes('--yes');
51
+ await runInstall({ target, autoYes });
52
+ return;
53
+ }
54
+ case 'uninstall': {
55
+ const target = rest.includes('--user') ? 'user' : 'project';
56
+ const autoYes = rest.includes('-y') || rest.includes('--yes');
57
+ await runUninstall({ target, autoYes });
58
+ return;
59
+ }
60
+ case 'catalog': {
61
+ const sub = rest[0];
62
+ if (sub === 'edit') { await runCatalogEdit(); return; }
63
+ if (sub === 'lint') { await runCatalogLint(); return; }
64
+ process.stderr.write(`unknown catalog subcommand: ${sub}\n${USAGE}`);
65
+ process.exit(2);
66
+ return;
67
+ }
68
+ case 'status':
69
+ await runStatus();
70
+ return;
71
+ case 'doctor':
72
+ await runDoctor();
73
+ return;
74
+ case 'daemon': {
75
+ const sub = rest[0];
76
+ if (sub === 'start') { await runDaemonStart({ argv: rest.slice(1) }); return; }
77
+ process.stderr.write(`unknown daemon subcommand: ${sub}\n${USAGE}`);
78
+ process.exit(2);
79
+ return;
80
+ }
81
+ case 'hook': {
82
+ const event = rest[0];
83
+ switch (event) {
84
+ case 'session-start': await runSessionStart(); return;
85
+ case 'user-prompt': await runUserPrompt(); return;
86
+ case 'pre-tool-use': await runPreToolUse(); return;
87
+ case 'stop': await runStop(); return;
88
+ case 'session-end': await runSessionEnd(); return;
89
+ default:
90
+ process.stderr.write(`unknown hook event: ${event}\n${USAGE}`);
91
+ process.exit(2);
92
+ }
93
+ return;
94
+ }
95
+ default:
96
+ process.stderr.write(`unknown command: ${cmd}\n${USAGE}`);
97
+ process.exit(2);
98
+ }
99
+ }
100
+
101
+ main().catch((err) => {
102
+ process.stderr.write(`spotter: ${err.stack || err.message || err}\n`);
103
+ process.exit(err.exitCode ?? 2);
104
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "claude-spotter",
3
+ "version": "0.1.0",
4
+ "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
+ "type": "module",
6
+ "bin": {
7
+ "spotter": "./bin/spotter.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.mjs"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
15
+ "keywords": [
16
+ "claude",
17
+ "claude-code",
18
+ "hooks",
19
+ "agent",
20
+ "tool-use",
21
+ "audit",
22
+ "spotter"
23
+ ],
24
+ "author": "kitepon",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/kitepon-rgb/Spotter.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/kitepon-rgb/Spotter/issues"
32
+ },
33
+ "homepage": "https://github.com/kitepon-rgb/Spotter#readme",
34
+ "engines": {
35
+ "node": ">=22.5.0"
36
+ },
37
+ "dependencies": {
38
+ "js-yaml": "^4.1.0"
39
+ },
40
+ "files": [
41
+ "bin",
42
+ "src",
43
+ "templates",
44
+ "README.md",
45
+ "LICENSE",
46
+ "CHANGELOG.md"
47
+ ]
48
+ }
@@ -0,0 +1,56 @@
1
+ // `spotter catalog lint` — schema validation + test_cases execution against live Haiku.
2
+ // §11: v0.1 completion metric = this command passes. No mocks (§14.1 silent-fallback discipline).
3
+
4
+ import { loadCatalog } from './loader.mjs';
5
+ import { buildFirstStagePrompt, parseHaikuResponse } from '../daemon/haiku-caller.mjs';
6
+
7
+ export async function runLint({ catalogPath, haikuCaller, writeLine }) {
8
+ writeLine(`lint: loading ${catalogPath}`);
9
+ const catalog = await loadCatalog(catalogPath);
10
+ writeLine(`lint: loaded ${catalog.tools.length} tools, validating test_cases...`);
11
+
12
+ const totalCases = catalog.tools.reduce(
13
+ (sum, t) => sum + (Array.isArray(t.test_cases) ? t.test_cases.length : 0),
14
+ 0
15
+ );
16
+ if (totalCases === 0) {
17
+ writeLine('lint: no test_cases present — schema validation only, passed');
18
+ return { passed: 0, failed: 0, total: 0 };
19
+ }
20
+
21
+ let passed = 0;
22
+ const failures = [];
23
+
24
+ for (const tool of catalog.tools) {
25
+ if (!Array.isArray(tool.test_cases)) continue;
26
+ for (const tc of tool.test_cases) {
27
+ const prompt = buildFirstStagePrompt({
28
+ catalog,
29
+ userInput: tc.user_input,
30
+ });
31
+ const rawResponse = await haikuCaller(prompt);
32
+ const parsed = parseHaikuResponse(rawResponse);
33
+ const detectedNames = parsed.missing_tools.map((m) => m.name);
34
+ const hit = detectedNames.includes(tc.expected_tool);
35
+ if (hit) {
36
+ passed += 1;
37
+ writeLine(` PASS ${tool.name} :: "${tc.user_input}" → ${tc.expected_tool}`);
38
+ } else {
39
+ failures.push({
40
+ tool: tool.name,
41
+ user_input: tc.user_input,
42
+ expected: tc.expected_tool,
43
+ detected: detectedNames,
44
+ });
45
+ writeLine(
46
+ ` FAIL ${tool.name} :: "${tc.user_input}" → expected ${tc.expected_tool}, detected [${detectedNames.join(', ') || '-'}]`
47
+ );
48
+ }
49
+ }
50
+ }
51
+
52
+ const failed = failures.length;
53
+ writeLine('');
54
+ writeLine(`lint summary: ${passed}/${totalCases} passed, ${failed} failed`);
55
+ return { passed, failed, total: totalCases, failures };
56
+ }
@@ -0,0 +1,36 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import yaml from 'js-yaml';
3
+ import { validateCatalog, CatalogSchemaError } from './schema.mjs';
4
+
5
+ export class CatalogLoadError extends Error {
6
+ constructor(message, cause) {
7
+ super(message);
8
+ this.name = 'CatalogLoadError';
9
+ if (cause) this.cause = cause;
10
+ }
11
+ }
12
+
13
+ export async function loadCatalog(path) {
14
+ let raw;
15
+ try {
16
+ raw = await readFile(path, 'utf8');
17
+ } catch (err) {
18
+ if (err.code === 'ENOENT') {
19
+ throw new CatalogLoadError(`catalog not found: ${path}`, err);
20
+ }
21
+ throw new CatalogLoadError(`cannot read catalog: ${path}`, err);
22
+ }
23
+
24
+ let parsed;
25
+ try {
26
+ parsed = yaml.load(raw);
27
+ } catch (err) {
28
+ throw new CatalogLoadError(`yaml parse failed in ${path}: ${err.message}`, err);
29
+ }
30
+
31
+ // schema errors propagate as CatalogSchemaError — the caller can distinguish them
32
+ validateCatalog(parsed);
33
+ return parsed;
34
+ }
35
+
36
+ export { CatalogSchemaError };
@@ -0,0 +1,109 @@
1
+ // Tool catalog schema validation.
2
+ // §0 rule: catalog shape violations throw. No silent coercion, no defaults-that-hide-bugs.
3
+
4
+ const REQUIRED_TOOL_FIELDS = ['name', 'purpose', 'when_to_use'];
5
+ const REQUIRED_TEST_CASE_FIELDS = ['user_input', 'expected_tool'];
6
+
7
+ export class CatalogSchemaError extends Error {
8
+ constructor(message, path) {
9
+ super(`catalog schema violation at ${path}: ${message}`);
10
+ this.name = 'CatalogSchemaError';
11
+ this.path = path;
12
+ }
13
+ }
14
+
15
+ export function validateCatalog(raw) {
16
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
17
+ throw new CatalogSchemaError('root must be an object', '$');
18
+ }
19
+ if (raw.version !== 1) {
20
+ throw new CatalogSchemaError(`version must be 1, got ${JSON.stringify(raw.version)}`, '$.version');
21
+ }
22
+ if (!Array.isArray(raw.tools)) {
23
+ throw new CatalogSchemaError('tools must be an array', '$.tools');
24
+ }
25
+ if (raw.tools.length === 0) {
26
+ throw new CatalogSchemaError('tools must contain at least one entry', '$.tools');
27
+ }
28
+
29
+ const seen = new Set();
30
+ raw.tools.forEach((tool, i) => validateTool(tool, `$.tools[${i}]`, seen));
31
+
32
+ return raw;
33
+ }
34
+
35
+ function validateTool(tool, path, seen) {
36
+ if (tool === null || typeof tool !== 'object' || Array.isArray(tool)) {
37
+ throw new CatalogSchemaError('tool entry must be an object', path);
38
+ }
39
+ for (const field of REQUIRED_TOOL_FIELDS) {
40
+ if (!(field in tool)) {
41
+ throw new CatalogSchemaError(`missing required field "${field}"`, path);
42
+ }
43
+ }
44
+ if (typeof tool.name !== 'string' || tool.name.length === 0) {
45
+ throw new CatalogSchemaError('name must be a non-empty string', `${path}.name`);
46
+ }
47
+ if (seen.has(tool.name)) {
48
+ throw new CatalogSchemaError(`duplicate tool name "${tool.name}"`, `${path}.name`);
49
+ }
50
+ seen.add(tool.name);
51
+
52
+ if (typeof tool.purpose !== 'string' || tool.purpose.trim().length === 0) {
53
+ throw new CatalogSchemaError('purpose must be a non-empty string', `${path}.purpose`);
54
+ }
55
+ if (!Array.isArray(tool.when_to_use) || tool.when_to_use.length === 0) {
56
+ throw new CatalogSchemaError('when_to_use must be a non-empty array', `${path}.when_to_use`);
57
+ }
58
+ tool.when_to_use.forEach((entry, i) => {
59
+ if (typeof entry !== 'string' || entry.length === 0) {
60
+ throw new CatalogSchemaError('when_to_use entries must be non-empty strings', `${path}.when_to_use[${i}]`);
61
+ }
62
+ });
63
+
64
+ if ('test_cases' in tool) {
65
+ if (!Array.isArray(tool.test_cases)) {
66
+ throw new CatalogSchemaError('test_cases must be an array', `${path}.test_cases`);
67
+ }
68
+ tool.test_cases.forEach((tc, i) => validateTestCase(tc, `${path}.test_cases[${i}]`));
69
+ }
70
+ }
71
+
72
+ function validateTestCase(tc, path) {
73
+ if (tc === null || typeof tc !== 'object' || Array.isArray(tc)) {
74
+ throw new CatalogSchemaError('test case must be an object', path);
75
+ }
76
+ for (const field of REQUIRED_TEST_CASE_FIELDS) {
77
+ if (!(field in tc)) {
78
+ throw new CatalogSchemaError(`missing required field "${field}"`, path);
79
+ }
80
+ }
81
+ if (typeof tc.user_input !== 'string' || tc.user_input.length === 0) {
82
+ throw new CatalogSchemaError('user_input must be a non-empty string', `${path}.user_input`);
83
+ }
84
+ if (typeof tc.expected_tool !== 'string' || tc.expected_tool.length === 0) {
85
+ throw new CatalogSchemaError('expected_tool must be a non-empty string', `${path}.expected_tool`);
86
+ }
87
+ }
88
+
89
+ // Projection for Haiku first-stage judgement (§6.3).
90
+ export function projectForFirstStage(catalog) {
91
+ return catalog.tools.map((tool) => ({
92
+ name: tool.name,
93
+ purpose: tool.purpose,
94
+ when_to_use: tool.when_to_use,
95
+ }));
96
+ }
97
+
98
+ // Projection for Haiku final-stage judgement — adds usage/examples when name matches.
99
+ export function projectForFinalStage(catalog, candidateNames) {
100
+ return catalog.tools
101
+ .filter((tool) => candidateNames.includes(tool.name))
102
+ .map((tool) => ({
103
+ name: tool.name,
104
+ purpose: tool.purpose,
105
+ when_to_use: tool.when_to_use,
106
+ usage: tool.usage ?? null,
107
+ examples: tool.examples ?? [],
108
+ }));
109
+ }
@@ -0,0 +1,37 @@
1
+ // `spotter catalog edit|lint`
2
+ //
3
+ // edit: open the catalog in $EDITOR
4
+ // lint: validate schema + run test_cases against live Haiku (v0.1 completion metric)
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { runLint } from '../catalog/lint.mjs';
10
+ import { createHaikuCaller } from '../daemon/haiku-caller.mjs';
11
+
12
+ const CATALOG_PATH = join(homedir(), '.spotter', 'tool-catalog', 'tools.yaml');
13
+
14
+ export async function runCatalogEdit() {
15
+ const editor = process.env.EDITOR || process.env.VISUAL || defaultEditor();
16
+ await new Promise((resolve, reject) => {
17
+ const child = spawn(editor, [CATALOG_PATH], { stdio: 'inherit' });
18
+ child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`editor exited with ${code}`))));
19
+ child.on('error', reject);
20
+ });
21
+ }
22
+
23
+ function defaultEditor() {
24
+ return process.platform === 'win32' ? 'notepad' : 'vi';
25
+ }
26
+
27
+ export async function runCatalogLint({ catalogPath = CATALOG_PATH } = {}) {
28
+ const haikuCaller = createHaikuCaller({ timeoutMs: 30_000 });
29
+ const result = await runLint({
30
+ catalogPath,
31
+ haikuCaller,
32
+ writeLine: (s) => console.log(s),
33
+ });
34
+ if (result.failed > 0) {
35
+ process.exit(1);
36
+ }
37
+ }
@@ -0,0 +1,41 @@
1
+ // `spotter daemon start|stop` — internal commands invoked by SessionStart/SessionEnd hooks.
2
+
3
+ import { startDaemon } from '../daemon/daemon.mjs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { open } from 'node:fs/promises';
7
+
8
+ function parseArgs(argv) {
9
+ const out = { sessionId: null };
10
+ for (let i = 0; i < argv.length; i += 1) {
11
+ if (argv[i] === '--session-id') {
12
+ out.sessionId = argv[i + 1];
13
+ i += 1;
14
+ }
15
+ }
16
+ return out;
17
+ }
18
+
19
+ export async function runDaemonStart({ argv }) {
20
+ const { sessionId } = parseArgs(argv);
21
+ if (!sessionId) {
22
+ process.stderr.write('spotter daemon start: --session-id is required\n');
23
+ process.exit(2);
24
+ }
25
+
26
+ const logFile = await open(
27
+ join(homedir(), '.spotter', 'logs', `daemon-${sessionId}.log`),
28
+ 'a'
29
+ );
30
+ const log = (msg) => {
31
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
32
+ logFile.write(line).catch(() => {});
33
+ };
34
+
35
+ const running = await startDaemon({ sessionId, logFn: log });
36
+ log(`started on ${running.path}`);
37
+
38
+ // Keep process alive; SessionEnd → shutdown event triggers server.close() which resolves the await.
39
+ await new Promise((resolve) => running.server.on('close', resolve));
40
+ log('server closed, exiting');
41
+ }
@@ -0,0 +1,74 @@
1
+ // `spotter doctor` — environment diagnostic.
2
+
3
+ import { access, readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { loadCatalog } from '../catalog/loader.mjs';
9
+
10
+ const execFileP = promisify(execFile);
11
+
12
+ export async function runDoctor() {
13
+ console.log('spotter doctor');
14
+ let warnings = 0;
15
+ let failures = 0;
16
+
17
+ // Node version
18
+ const nodeVersion = process.versions.node;
19
+ const major = parseInt(nodeVersion.split('.')[0], 10);
20
+ const minor = parseInt(nodeVersion.split('.')[1], 10);
21
+ const okNode = major > 22 || (major === 22 && minor >= 5);
22
+ mark(okNode, `Node.js ${nodeVersion}`, 'need >= 22.5');
23
+ if (!okNode) failures += 1;
24
+
25
+ // claude CLI — on Windows the entry is `claude.cmd`; route through cmd.exe /c
26
+ // rather than shell:true (DEP0190 on Node 24+).
27
+ try {
28
+ const opts = { timeout: 5_000, windowsHide: true };
29
+ const { stdout } = process.platform === 'win32'
30
+ ? await execFileP('cmd.exe', ['/c', 'claude', '--version'], opts)
31
+ : await execFileP('claude', ['--version'], opts);
32
+ mark(true, `claude CLI: ${stdout.trim()}`);
33
+ } catch (err) {
34
+ mark(false, 'claude CLI', `not found or failed: ${err.message}`);
35
+ failures += 1;
36
+ }
37
+
38
+ // ~/.spotter directories
39
+ const home = join(homedir(), '.spotter');
40
+ for (const sub of ['tool-catalog', 'runtime', 'workdir', 'logs']) {
41
+ const path = join(home, sub);
42
+ const ok = await exists(path);
43
+ mark(ok, `dir ${path}`);
44
+ if (!ok) warnings += 1;
45
+ }
46
+
47
+ // catalog
48
+ const catalogPath = join(home, 'tool-catalog', 'tools.yaml');
49
+ try {
50
+ const cat = await loadCatalog(catalogPath);
51
+ mark(true, `catalog: ${cat.tools.length} tools at ${catalogPath}`);
52
+ } catch (err) {
53
+ mark(false, 'catalog', err.message);
54
+ failures += 1;
55
+ }
56
+
57
+ console.log('');
58
+ if (failures > 0) {
59
+ console.log(`result: ${failures} failure(s), ${warnings} warning(s)`);
60
+ process.exit(1);
61
+ }
62
+ console.log(`result: OK (${warnings} warnings)`);
63
+ }
64
+
65
+ async function exists(path) {
66
+ try { await access(path); return true; }
67
+ catch { return false; }
68
+ }
69
+
70
+ function mark(ok, label, detail) {
71
+ const icon = ok ? 'OK ' : 'NG ';
72
+ const line = detail ? `${label} — ${detail}` : label;
73
+ console.log(` ${icon} ${line}`);
74
+ }