claude-spotter 1.5.12 → 1.5.13

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
@@ -3,6 +3,22 @@
3
3
  各節はそのversion公開時点の変更記録であり、後続versionにより置換された仕様を含む。
4
4
  現行runtime契約は[`docs/00_overview.md`](docs/00_overview.md)から辿る。
5
5
 
6
+ ## 1.5.13 — 2026-08-23
7
+
8
+ - **OS依存を`src/platform/`へ集約。** 6ファイルへ重複していたWindowsのcmd.exe /c wrap・
9
+ `.cmd` shim解決・`windowsHide`強制を[spawn.mjs](src/platform/spawn.mjs)の
10
+ `windowsCompatibleCommand` / `execFileWindowsSafe`へ一本化し、socket/Named Pipeの
11
+ path生成・権限・stale socket除去を[ipc.mjs](src/platform/ipc.mjs)へ、パス表記正規化と
12
+ PATH実行体探索を[paths.mjs](src/platform/paths.mjs)へ移設した。`windows-cli-shim.mjs`は
13
+ `platform/spawn.mjs`へ吸収した。
14
+ - **ベンダー依存の決定点を`src/host/adapters.mjs`へ集約。** hostごとのtool-db file名と
15
+ snapshot builder選択をadapter tableで引き、loader / refreshの`if (hostAgent === 'codex')`
16
+ 分岐を業務ロジックから消した。Claude側discovery実装は`investigate-codex.mjs`と対称の
17
+ [investigate-claude.mjs](src/tool-db/investigate-claude.mjs)へ逐語移設した。
18
+ - **公開契約は変更なし。** 既存のexport名・`{cmd, cmdArgs}` shape・hook/daemon IPC・
19
+ CLI表面・test fixtureは再exportで維持し、挙動変更ゼロ(`node --test` 593 pass)。
20
+ 配置規約はAGENTS.md「依存の配置規約」として正典化した。
21
+
6
22
  ## 1.5.12 — 2026-08-18
7
23
 
8
24
  - **Stop未観測の評価turnを次prompt時に採点して閉じる。** 実測でoutcome_missing 2,091件の94%が
@@ -1,7 +1,7 @@
1
1
  # Spotter評価dashboard運用
2
2
 
3
- 現行npm配布版: **v1.5.12**(2026-08-18)。v1.5.12は評価の採点方式と表示名
4
- (提案適合率(上限))を変更し、dashboardのrouting構成は変更していない。
3
+ 現行npm配布版: **v1.5.13**(2026-08-23)。v1.5.13はOS依存・ベンダー依存の内部配置だけを
4
+ 変更した挙動同一リファクタで、評価・dashboardのrouting構成は変更していない。
5
5
 
6
6
  この文書はservice設定の正本であり、各端末に現在installされているnpm versionの台帳ではない。
7
7
  端末versionは対象端末で`spotter --version`を実行して確認する。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.5.12",
3
+ "version": "1.5.13",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,7 +7,7 @@ import { delimiter, dirname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createAuditorBackend, selectAuditorBackend } from '../core/auditor-backend.mjs';
9
9
  import { resolveCodexAuditorModelSelection } from '../core/codex-auditor-model-policy.mjs';
10
- import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
10
+ import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
11
11
  import { codexLastAssistantMessage, readCodexToolUsage } from '../core/codex-transcript.mjs';
12
12
  import { readLocal } from '../tool-db/refresh.mjs';
13
13
  import { spawnRefreshDetached } from '../hooks/spawn-daemon.mjs';
@@ -8,7 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { loadDb, globalDbPath, localDbPath } from '../tool-db/loader.mjs';
9
9
  import { findSpotterMarker } from '../hooks/lib.mjs';
10
10
  import { codexHookDiagnostics } from './codex-hook-cmd.mjs';
11
- import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
11
+ import { buildWindowsCompatibleInvocation, execFileWindowsSafe } from '../platform/spawn.mjs';
12
12
 
13
13
  const execFileP = promisify(execFile);
14
14
 
@@ -23,13 +23,9 @@ export async function runDoctor() {
23
23
  mark(okNode, `Node.js ${nodeVersion}`, 'need >= 22.13');
24
24
  if (!okNode) failures += 1;
25
25
 
26
- // claude CLI — on Windows the entry is `claude.cmd`; route through cmd.exe /c
27
- // rather than shell:true (DEP0190 on Node 24+).
26
+ // claude CLI — Windows の .cmd shim 解決は src/platform/spawn.mjs が所有する。
28
27
  try {
29
- const opts = { timeout: 5_000, windowsHide: true };
30
- const { stdout } = process.platform === 'win32'
31
- ? await execFileP('cmd.exe', ['/c', 'claude', '--version'], opts)
32
- : await execFileP('claude', ['--version'], opts);
28
+ const { stdout } = await execFileWindowsSafe('claude', ['--version'], { timeout: 5_000 });
33
29
  mark(true, `claude CLI: ${stdout.trim()}`);
34
30
  } catch (err) {
35
31
  mark(false, 'claude CLI', `not found or failed: ${err.message}`);
@@ -15,7 +15,7 @@
15
15
  import { mkdir, writeFile, readFile, access, realpath } from 'node:fs/promises';
16
16
  import { constants as fsConstants } from 'node:fs';
17
17
  import { spawnSync } from 'node:child_process';
18
- import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
18
+ import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
19
19
  import { homedir } from 'node:os';
20
20
  import { join, resolve, dirname, delimiter, extname } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
@@ -1,9 +1,5 @@
1
- import { statSync } from 'node:fs';
2
- import { posix, win32 } from 'node:path';
3
-
4
1
  // Configuration-time (synchronous) check for whether `codex` is on PATH.
5
- // No subprocess is spawned. The function walks env.PATH and tests each candidate
6
- // with `fs.statSync`; PATHEXT-equivalent extensions are added on Windows.
2
+ // No subprocess is spawned. OS依存のPATH walkは src/platform/paths.mjs が所有する。
7
3
  //
8
4
  // This is consumed by `selectByPolicy` in auditor-backend.mjs to choose the
9
5
  // Claude-host primary auditor: when codex is reachable, the daemon prefers
@@ -11,41 +7,16 @@ import { posix, win32 } from 'node:path';
11
7
  // throw `AuditorBackendError` — selection-time availability does not become a
12
8
  // runtime fallback.
13
9
 
14
- const WINDOWS_EXTS = ['.cmd', '.exe', '.bat'];
10
+ import { isExecutableOnPath } from '../platform/paths.mjs';
15
11
 
16
12
  export function isCodexCliAvailable({
17
13
  env = process.env,
18
14
  platform = process.platform,
19
- fileExists = defaultFileExists,
15
+ fileExists,
20
16
  } = {}) {
21
- const pathVar = pickPathVar(env, platform);
22
- if (typeof pathVar !== 'string' || pathVar.length === 0) return false;
23
- const sep = platform === 'win32' ? ';' : ':';
24
- const join = platform === 'win32' ? win32.join : posix.join;
25
- const candidates = platform === 'win32'
26
- ? WINDOWS_EXTS.map((ext) => `codex${ext}`)
27
- : ['codex'];
28
- for (const rawDir of pathVar.split(sep)) {
29
- const dir = rawDir.trim();
30
- if (dir.length === 0) continue;
31
- for (const candidate of candidates) {
32
- if (fileExists(join(dir, candidate))) return true;
33
- }
34
- }
35
- return false;
36
- }
37
-
38
- function pickPathVar(env, platform) {
39
- if (platform === 'win32') {
40
- return env?.Path ?? env?.PATH ?? env?.path ?? '';
41
- }
42
- return env?.PATH ?? '';
43
- }
44
-
45
- function defaultFileExists(p) {
46
- try {
47
- return statSync(p).isFile();
48
- } catch {
49
- return false;
50
- }
17
+ return isExecutableOnPath('codex', {
18
+ env,
19
+ platform,
20
+ ...(fileExists ? { fileExists } : {}),
21
+ });
51
22
  }
@@ -10,7 +10,7 @@ import {
10
10
  resolveCodexAuditorModelSelection,
11
11
  } from './codex-auditor-model-policy.mjs';
12
12
  import { serializeAuditorPromptData, validateRecentContext } from './auditor-prompt-data.mjs';
13
- import { buildWindowsCompatibleInvocation, terminateProcessTree } from './windows-cli-shim.mjs';
13
+ import { buildWindowsCompatibleInvocation, terminateProcessTree } from '../platform/spawn.mjs';
14
14
 
15
15
  const DEFAULT_CODEX_CLI_TIMEOUT_MS = 45_000;
16
16
  const DEFAULT_CODEX_CLI_MODEL = CODEX_AUDITOR_MODEL_POLICY.production.model;
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { toSpotterJudgment } from './judgment.mjs';
7
7
  import { filterCatalogMisses } from './auditor-response.mjs';
8
8
  import { AuditorBackendError } from './auditor-error.mjs';
9
- import { buildWindowsCompatibleInvocation } from './windows-cli-shim.mjs';
9
+ import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
10
10
 
11
11
  const execFileP = promisify(execFile);
12
12
  const DEFAULT_CODEX_SIDECAR_AUDITOR_TIMEOUT_MS = 45_000;
@@ -16,7 +16,7 @@ import {
16
16
  createSidecarResultRecord,
17
17
  spotterFindingsToSidecarContextBlocks,
18
18
  } from './sidecar-context.mjs';
19
- import { buildWindowsCompatibleInvocation } from './windows-cli-shim.mjs';
19
+ import { buildWindowsCompatibleInvocation } from '../platform/spawn.mjs';
20
20
 
21
21
  const execFileP = promisify(execFile);
22
22
  const WORKFLOW_MAP = {
@@ -483,7 +483,7 @@ export async function startDaemon({
483
483
  } catch (error) {
484
484
  await observeFailure('daemon_persistence');
485
485
  await new Promise((resolve) => server.close(resolve)).catch(() => {});
486
- if (process.platform !== 'win32') await unlink(path).catch(() => {});
486
+ await removeStaleSocketFile(path).catch(() => {});
487
487
  throw error;
488
488
  }
489
489
 
@@ -538,14 +538,10 @@ async function shutdown(server, sessionId, logFn, evaluationStore = null) {
538
538
  } catch (err) {
539
539
  logFn(`shutdown: server.close failed: ${err.message}`);
540
540
  }
541
- if (process.platform !== 'win32') {
542
- try {
543
- await unlink(socketPath(sessionId));
544
- } catch (err) {
545
- if (err.code !== 'ENOENT') {
546
- logFn(`shutdown: unlink socket failed: ${err.message}`);
547
- }
548
- }
541
+ try {
542
+ await removeStaleSocketFile(socketPath(sessionId));
543
+ } catch (err) {
544
+ logFn(`shutdown: unlink socket failed: ${err.message}`);
549
545
  }
550
546
  try {
551
547
  await unlink(pidFilePath(sessionId));
@@ -14,6 +14,7 @@
14
14
  // puts the catalog only in the first turn's user message; the session retains it for free.
15
15
 
16
16
  import { spawn } from 'node:child_process';
17
+ import { windowsCompatibleCommand } from '../platform/spawn.mjs';
17
18
  import { homedir, tmpdir } from 'node:os';
18
19
  import { join } from 'node:path';
19
20
  import { mkdir, writeFile, unlink, open } from 'node:fs/promises';
@@ -241,9 +242,7 @@ export async function preparePromptFile(wirePrompt) {
241
242
  };
242
243
  }
243
244
 
244
- // On Windows, the `claude` entry is typically a .cmd shim which Node's spawn cannot locate
245
- // without going through the shell. We use cmd.exe /c explicitly rather than spawn({ shell:
246
- // true }) because the latter triggers DEP0190 on Node 24+.
245
+ // Windows の .cmd shim 解決 (cmd.exe /c wrap) は src/platform/spawn.mjs が所有する。
247
246
  //
248
247
  // v1.3.0: `--strict-mcp-config --mcp-config <empty>` を必ず付けて MCP load を無効化する。
249
248
  // `mcpConfigPath` は呼び出し側 (createHaikuCaller) が必ず渡す前提。テストで shape を pin。
@@ -255,10 +254,8 @@ export function buildSpawnArgs({ claudeBin, model, sessionId, resume, mcpConfigP
255
254
  ? ['-p', '--resume', sessionId, '--model', model]
256
255
  : ['-p', '--session-id', sessionId, '--model', model];
257
256
  const args = [...baseArgs, '--strict-mcp-config', '--mcp-config', mcpConfigPath];
258
- if (process.platform === 'win32') {
259
- return { cmd: 'cmd.exe', cmdArgs: ['/c', claudeBin, ...args] };
260
- }
261
- return { cmd: claudeBin, cmdArgs: args };
257
+ const invocation = windowsCompatibleCommand(claudeBin, args);
258
+ return { cmd: invocation.command, cmdArgs: invocation.args };
262
259
  }
263
260
 
264
261
  // Invoke `claude -p` in the isolated workdir. Returns raw stdout.
@@ -4,54 +4,11 @@
4
4
 
5
5
  import net from 'node:net';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { chmod, mkdir, unlink } from 'node:fs/promises';
8
- import { homedir } from 'node:os';
9
- import { join } from 'node:path';
7
+ import { socketPath } from '../platform/ipc.mjs';
10
8
 
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, mode: 0o700 });
25
- if (process.platform !== 'win32') {
26
- await chmod(RUNTIME_DIR, 0o700);
27
- }
28
- return RUNTIME_DIR;
29
- }
30
-
31
- export async function secureSocketFile(path) {
32
- if (process.platform === 'win32') return;
33
- await chmod(path, 0o600);
34
- }
35
-
36
- // Remove a Unix domain socket file left behind by a daemon that died ungracefully (SIGKILL,
37
- // crash, or machine sleep before SessionEnd ran stop()'s cleanup). stop() unlinks the socket only
38
- // on graceful shutdown, so an orphan file persists otherwise. The CALLER MUST have already
39
- // confirmed no live daemon owns this session (assertNoLiveDaemon) — only then is the file known to
40
- // be stale. Without removing it, server.listen(path) fails with EADDRINUSE and the daemon dies
41
- // before "daemon listening", so every resurrect attempt crash-loops and the session is never
42
- // audited again (observed: Kikoeru session 83d7aa04 — 5 failed restarts, all stuck at backend
43
- // selection). ENOENT is the normal first-run / no-stale-file case and is ignored; any other error
44
- // is rethrown (§0: no silent swallow). No-op on Windows — a Named Pipe vanishes when its owning
45
- // process exits, so there is no filesystem artifact to remove.
46
- export async function removeStaleSocketFile(path) {
47
- if (process.platform === 'win32') return;
48
- try {
49
- await unlink(path);
50
- } catch (err) {
51
- if (err?.code === 'ENOENT') return;
52
- throw err;
53
- }
54
- }
9
+ // OS依存のsocket/pipe面 (path生成・権限・stale socket除去) は src/platform/ipc.mjs が
10
+ // 所有する。既存importerのためここから同名で再exportする。
11
+ export { socketPath, ensureRuntimeDir, secureSocketFile, removeStaleSocketFile } from '../platform/ipc.mjs';
55
12
 
56
13
  // Request/response over a single connection. Used by hooks.
57
14
  export function sendRequest({ sessionId, event, payload, timeoutMs }) {
@@ -0,0 +1,53 @@
1
+ // AIベンダー(host agent)依存の決定点の唯一の置き場。
2
+ // 「どのhostがどのtool-db fileを持ち、どのdiscovery経路でsnapshotを作るか」は
3
+ // このtableだけが知る。呼び出し側(loader / refresh / CLI)は adapter を引いて
4
+ // 使うだけで、`if (hostAgent === 'codex')` 分岐を業務ロジックに書かない。
5
+ //
6
+ // ベンダー固有の実装本体は従来どおり investigate-claude.mjs / investigate-codex.mjs
7
+ // が持つ。片方のhostのrefreshがもう片方のDBをprune / overwriteしない契約
8
+ // (AGENTS.md「ツールカタログはhost-local tool-db」)はこの分離が担保する。
9
+
10
+ import { buildInvestigationSnapshot } from '../tool-db/investigate-claude.mjs';
11
+ import { buildCodexInvestigationSnapshot } from '../tool-db/investigate-codex.mjs';
12
+
13
+ const CLAUDE_ADAPTER = Object.freeze({
14
+ hostAgent: 'claude',
15
+ toolDbFileName: 'tool-db.json',
16
+ buildSnapshot: ({ logFn, claudeBin, projectRoot }) =>
17
+ buildInvestigationSnapshot({ logFn, claudeBin, projectRoot }),
18
+ });
19
+
20
+ const CODEX_ADAPTER = Object.freeze({
21
+ hostAgent: 'codex',
22
+ toolDbFileName: 'tool-db.codex.json',
23
+ buildSnapshot: ({ logFn, codexBin, projectRoot }) =>
24
+ buildCodexInvestigationSnapshot({ logFn, codexBin, projectRoot }),
25
+ });
26
+
27
+ // automation (CI等) は従来からClaude経路のdiscoveryを使い、DB fileだけ分離する。
28
+ const AUTOMATION_ADAPTER = Object.freeze({
29
+ hostAgent: 'automation',
30
+ toolDbFileName: 'tool-db.automation.json',
31
+ buildSnapshot: ({ logFn, claudeBin, projectRoot }) =>
32
+ buildInvestigationSnapshot({ logFn, claudeBin, projectRoot }),
33
+ });
34
+
35
+ const ADAPTERS = Object.freeze({
36
+ claude: CLAUDE_ADAPTER,
37
+ codex: CODEX_ADAPTER,
38
+ automation: AUTOMATION_ADAPTER,
39
+ });
40
+
41
+ export function normalizeToolDbHostAgent(hostAgent = 'claude') {
42
+ if (hostAgent === undefined || hostAgent === null || hostAgent === '') {
43
+ return 'claude';
44
+ }
45
+ if (Object.hasOwn(ADAPTERS, hostAgent)) {
46
+ return hostAgent;
47
+ }
48
+ throw new TypeError(`tool-db hostAgent must be claude, codex, or automation; got ${hostAgent}`);
49
+ }
50
+
51
+ export function getHostAdapter(hostAgent = 'claude') {
52
+ return ADAPTERS[normalizeToolDbHostAgent(hostAgent)];
53
+ }
@@ -0,0 +1,52 @@
1
+ // OS依存のhook⇄daemon IPC面の唯一の置き場。
2
+ // Unix domain socket (macOS/Linux) と Named Pipe (Windows) の差はこのファイルが
3
+ // 所有し、呼び出し側 (transport / daemon) は process.platform を見ない。
4
+
5
+ import { chmod, mkdir, unlink } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ const RUNTIME_DIR = join(homedir(), '.spotter', 'runtime');
10
+
11
+ export function socketPath(sessionId) {
12
+ if (!sessionId || typeof sessionId !== 'string') {
13
+ throw new TypeError('sessionId must be a non-empty string');
14
+ }
15
+ if (process.platform === 'win32') {
16
+ return `\\\\.\\pipe\\spotter-${sessionId}`;
17
+ }
18
+ return join(RUNTIME_DIR, `session-${sessionId}.sock`);
19
+ }
20
+
21
+ export async function ensureRuntimeDir() {
22
+ await mkdir(RUNTIME_DIR, { recursive: true, mode: 0o700 });
23
+ if (process.platform !== 'win32') {
24
+ await chmod(RUNTIME_DIR, 0o700);
25
+ }
26
+ return RUNTIME_DIR;
27
+ }
28
+
29
+ export async function secureSocketFile(path) {
30
+ if (process.platform === 'win32') return;
31
+ await chmod(path, 0o600);
32
+ }
33
+
34
+ // Remove a Unix domain socket file left behind by a daemon that died ungracefully (SIGKILL,
35
+ // crash, or machine sleep before SessionEnd ran stop()'s cleanup). stop() unlinks the socket only
36
+ // on graceful shutdown, so an orphan file persists otherwise. The CALLER MUST have already
37
+ // confirmed no live daemon owns this session (assertNoLiveDaemon) — only then is the file known to
38
+ // be stale. Without removing it, server.listen(path) fails with EADDRINUSE and the daemon dies
39
+ // before "daemon listening", so every resurrect attempt crash-loops and the session is never
40
+ // audited again (observed: Kikoeru session 83d7aa04 — 5 failed restarts, all stuck at backend
41
+ // selection). ENOENT is the normal first-run / no-stale-file case and is ignored; any other error
42
+ // is rethrown (§0: no silent swallow). No-op on Windows — a Named Pipe vanishes when its owning
43
+ // process exits, so there is no filesystem artifact to remove.
44
+ export async function removeStaleSocketFile(path) {
45
+ if (process.platform === 'win32') return;
46
+ try {
47
+ await unlink(path);
48
+ } catch (err) {
49
+ if (err?.code === 'ENOENT') return;
50
+ throw err;
51
+ }
52
+ }
@@ -0,0 +1,60 @@
1
+ // OS依存のパス表記・PATH探索の唯一の置き場。
2
+
3
+ import { statSync } from 'node:fs';
4
+ import { posix, win32 } from 'node:path';
5
+
6
+ // Synchronous PATH walk: is `command` reachable as an executable file on PATH?
7
+ // No subprocess is spawned. Windows adds PATHEXT-equivalent extensions and reads
8
+ // PATH case-insensitively (Path / PATH / path).
9
+ export function isExecutableOnPath(command, {
10
+ env = process.env,
11
+ platform = process.platform,
12
+ windowsExtensions = ['.cmd', '.exe', '.bat'],
13
+ fileExists = defaultFileExists,
14
+ } = {}) {
15
+ const pathVar = platform === 'win32'
16
+ ? (env?.Path ?? env?.PATH ?? env?.path ?? '')
17
+ : (env?.PATH ?? '');
18
+ if (typeof pathVar !== 'string' || pathVar.length === 0) return false;
19
+ const sep = platform === 'win32' ? ';' : ':';
20
+ const join = platform === 'win32' ? win32.join : posix.join;
21
+ const candidates = platform === 'win32'
22
+ ? windowsExtensions.map((ext) => `${command}${ext}`)
23
+ : [command];
24
+ for (const rawDir of pathVar.split(sep)) {
25
+ const dir = rawDir.trim();
26
+ if (dir.length === 0) continue;
27
+ for (const candidate of candidates) {
28
+ if (fileExists(join(dir, candidate))) return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function defaultFileExists(p) {
35
+ try {
36
+ return statSync(p).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ // Normalize an absolute project path for matching against config keys (e.g.
43
+ // `~/.claude.json` `projects[]`). Representation can drift across:
44
+ // - separator: `\` on Windows vs `/`
45
+ // - drive-letter case: `C:\` vs `c:\` on Windows
46
+ // - trailing slash: `/foo/bar` vs `/foo/bar/`
47
+ // On Windows we canonicalize to forward slashes and lower-case (case-insensitive
48
+ // filesystem). On POSIX, `\` is a legal filename character — collapsing it to `/`
49
+ // would conflate genuinely distinct paths (e.g. literal `C:\Users\u\proj` vs the
50
+ // hypothetical POSIX path `C:/Users/u/proj`), and case stays significant. POSIX
51
+ // only normalizes the trailing slash.
52
+ export function normalizeProjectPath(p) {
53
+ if (typeof p !== 'string' || p.length === 0) return '';
54
+ let s = p;
55
+ if (process.platform === 'win32') {
56
+ s = s.replace(/\\/g, '/').toLowerCase();
57
+ }
58
+ s = s.replace(/\/+$/, '');
59
+ return s;
60
+ }
@@ -1,6 +1,49 @@
1
- import { spawn } from 'node:child_process';
1
+ // OS依存のプロセス起動プリミティブの唯一の置き場。
2
+ // Windows の .cmd shim 解決・cmd.exe /c wrap・windowsHide・process tree 終了は
3
+ // すべてこのファイルが所有し、呼び出し側は process.platform を見ない。
4
+ //
5
+ // 2 種類の Windows コマンド解決を提供する:
6
+ // - windowsCompatibleCommand: 素朴な cmd.exe /c wrap (.exe は直接起動)。
7
+ // bare 名 / .cmd shim を PATHEXT 経由で起動する高頻度経路用。
8
+ // - buildWindowsCompatibleInvocation: npm shim の実体 (.cjs/.mjs/.js) を
9
+ // 解決して node で直接起動する精密経路。cmd.exe を避けたい場合に使う。
10
+
11
+ import { execFile, spawn } from 'node:child_process';
2
12
  import { readFileSync, statSync } from 'node:fs';
3
13
  import { win32 } from 'node:path';
14
+ import { promisify } from 'node:util';
15
+
16
+ const execFileP = promisify(execFile);
17
+
18
+ // On Windows, npm-global CLI tools (e.g. `claude`, `codex`, `claude-mermaid`) ship as
19
+ // `<name>.cmd` batch wrappers. Node's `child_process` without `shell: true` calls
20
+ // Windows `CreateProcess`, which only directly executes `.exe` files — it does NOT
21
+ // search PATHEXT for `.cmd`/`.bat` shims when given a bare command name, so
22
+ // `spawn('claude', ...)` fails with ENOENT even though `claude.cmd` is on PATH.
23
+ //
24
+ // We route any non-`.exe` command through `cmd.exe /c` on Windows, which makes
25
+ // PATHEXT lookup apply and runs both `.cmd` shims and bare names transparently.
26
+ // Absolute `.exe` paths stay un-wrapped because (a) they spawn correctly as-is and
27
+ // (b) wrapping them through `cmd.exe /c "<path with spaces>" args` runs into
28
+ // cmd.exe's quoting rules for paths containing spaces, which add risk for zero
29
+ // benefit. We use `cmd.exe /c` explicitly rather than `spawn({ shell: true })`
30
+ // because the latter triggers DEP0190 on Node 24+ and re-introduces
31
+ // argument-quoting risks (caveat:
32
+ // `windows-node-spawn-claude-fails-with-enoent-because-claude-is-a-cmd-wrapper`).
33
+ export function windowsCompatibleCommand(command, args = [], { platform = process.platform } = {}) {
34
+ if (platform !== 'win32' || /\.exe$/i.test(command)) {
35
+ return { command, args };
36
+ }
37
+ return { command: 'cmd.exe', args: ['/c', command, ...args] };
38
+ }
39
+
40
+ // execFile を windowsCompatibleCommand + windowsHide 強制で実行する。
41
+ // `windowsHide: true` はこの層で強制する — 無いと Windows で cmd.exe の console
42
+ // window が毎回 flash して入力フォーカスを奪う (v1.1.5 の実被弾)。
43
+ export async function execFileWindowsSafe(command, args = [], opts = {}) {
44
+ const invocation = windowsCompatibleCommand(command, args);
45
+ return execFileP(invocation.command, invocation.args, { ...opts, windowsHide: true });
46
+ }
4
47
 
5
48
  // Windows の npm global CLI は多くが `<name>.cmd` shim であり、Node の shell:false
6
49
  // 直接 spawn では PATHEXT 解決されない。cmd.exe を明示して Node 24 の shell:true
@@ -0,0 +1,71 @@
1
+ // Claude-native catalog investigation (investigate-codex.mjs の Claude 対称)。
2
+ // `claude mcp list` + Claude skills / sub-agents から (name → description) snapshot を作る。
3
+
4
+ import { listMcpToolsAll, listMcpServers, bellVisibleName } from './investigate-mcp.mjs';
5
+ import { getClaudeAiBaselineByServer } from './claude-ai-baseline.mjs';
6
+ import { listSkillsAll } from './investigate-skills.mjs';
7
+ import { listAgentsAll } from './investigate-agents.mjs';
8
+
9
+ // Pure filter: returns the subset of the claude.ai baseline whose server name is
10
+ // present in `presentServerNames`. Accepts a Set for O(1) membership. Extracted as
11
+ // a named export so it can be unit-tested without a live `claude` CLI.
12
+ export function filterClaudeAiBaseline(presentServerNames) {
13
+ const out = new Map();
14
+ for (const [serverName, tools] of getClaudeAiBaselineByServer()) {
15
+ if (!presentServerNames.has(serverName)) continue;
16
+ for (const [toolName, description] of Object.entries(tools)) {
17
+ out.set(toolName, description);
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+
23
+ // Build the (name → description) map for an investigation pass across all sources:
24
+ // - claude.ai MCP baseline (Gmail / Calendar / Drive — OAuth, not locally introspectable)
25
+ // - MCP servers via stdio + HTTP/SSE (user + project .mcp.json, live fetched)
26
+ // - Skills from user scope, project scope, and enabled plugins
27
+ // - Sub-agents from user scope, project scope, and enabled plugins
28
+ //
29
+ // Returns an in-memory snapshot the caller can use as the investigate() backend.
30
+ export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
31
+ const snapshot = new Map();
32
+
33
+ // Anthropic-provided `claude.ai ...` MCP servers. Hardcoded because the OAuth proxy
34
+ // is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
35
+ // Injected only for servers actually present in `claude mcp list` — otherwise phantom
36
+ // tools (Gmail/Calendar/Drive) leak into environments where those servers are not
37
+ // connected. See filterClaudeAiBaseline above.
38
+ const servers = await listMcpServers({ claudeBin, projectRoot });
39
+ const presentServerNames = new Set(servers.map((s) => s.name));
40
+ const baseline = filterClaudeAiBaseline(presentServerNames);
41
+ for (const [name, description] of baseline) {
42
+ snapshot.set(name, description);
43
+ }
44
+ if (baseline.size > 0) {
45
+ logFn(`claude.ai baseline injected: ${baseline.size} tools from ${[...presentServerNames].filter((n) => n.startsWith('claude.ai ')).join(', ')}`);
46
+ }
47
+
48
+ // MCP servers (stdio + user-registered HTTP/SSE). projectRoot forwards for
49
+ // project-scope `.mcp.json` merge (see mcp-config.mjs).
50
+ const mcp = await listMcpToolsAll({ logFn, claudeBin, projectRoot });
51
+ for (const [serverName, tools] of mcp.entries()) {
52
+ for (const tool of tools) {
53
+ if (!tool.description || tool.description.length === 0) continue;
54
+ snapshot.set(bellVisibleName(serverName, tool.name), tool.description);
55
+ }
56
+ }
57
+
58
+ // Skills (plugin-namespaced as `<plugin>:<name>`, or bare for user/project scope).
59
+ const skills = await listSkillsAll({ logFn, projectRoot });
60
+ for (const [name, description] of skills) {
61
+ snapshot.set(name, description);
62
+ }
63
+
64
+ // Sub-agents (bare name; project > user > plugin precedence resolved internally).
65
+ const agents = await listAgentsAll({ logFn, projectRoot });
66
+ for (const [name, description] of agents) {
67
+ snapshot.set(name, description);
68
+ }
69
+
70
+ return snapshot;
71
+ }
@@ -4,22 +4,16 @@
4
4
  // discovery path. Codex and Claude expose different MCP servers and skills, so a
5
5
  // Codex refresh must build a separate snapshot and write it to tool-db.codex.json.
6
6
 
7
- import { execFile } from 'node:child_process';
8
7
  import { readdir, readFile } from 'node:fs/promises';
9
8
  import { homedir } from 'node:os';
10
9
  import { join } from 'node:path';
11
- import { promisify } from 'node:util';
10
+ import { execFileWindowsSafe } from '../platform/spawn.mjs';
12
11
  import { bellVisibleName, listMcpToolsOne, splitArgs } from './investigate-mcp.mjs';
13
12
  import { readFrontmatter } from './frontmatter.mjs';
14
13
 
15
- const execFileP = promisify(execFile);
16
-
14
+ // Windows の .cmd shim 解決と windowsHide 強制は src/platform/spawn.mjs が所有する。
17
15
  async function execCodex(codexBin, args, opts) {
18
- const execOpts = { ...opts, windowsHide: true };
19
- if (process.platform === 'win32') {
20
- return execFileP('cmd.exe', ['/c', codexBin, ...args], execOpts);
21
- }
22
- return execFileP(codexBin, args, execOpts);
16
+ return execFileWindowsSafe(codexBin, args, opts);
23
17
  }
24
18
 
25
19
  export async function buildCodexInvestigationSnapshot({
@@ -10,29 +10,17 @@
10
10
  // 5. ← tools/list result (response with tools[] each having {name, description})
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
- import { execFile } from 'node:child_process';
14
- import { promisify } from 'node:util';
13
+ import { execFileWindowsSafe, windowsCompatibleCommand } from '../platform/spawn.mjs';
15
14
  import { listToolsHttp } from './investigate-mcp-http.mjs';
16
15
  import { readMcpServers, describeServer } from './mcp-config.mjs';
17
16
  import { version as SPOTTER_VERSION } from '../version.mjs';
18
17
 
19
- const execFileP = promisify(execFile);
20
-
21
18
  const PROTOCOL_VERSION = '2025-03-26'; // MCP protocol version we claim to speak.
22
19
  const HANDSHAKE_TIMEOUT_MS = 10_000;
23
20
 
24
- // On Windows, `claude` is a .cmd shim; Node's execFile cannot locate it directly without
25
- // going through cmd.exe. Matches the pattern in src/daemon/haiku-caller.mjs buildSpawnArgs.
26
- // We use cmd.exe /c rather than shell:true to avoid DEP0190 on Node 24+.
27
- // `windowsHide: true` is forced at this layer so every caller (listMcpServers,
28
- // getStdioConfig, etc.) is silent — without it a cmd.exe console window flashes on every
29
- // refresh, and those flashes steal keyboard focus on Windows.
21
+ // Windows の .cmd shim 解決と windowsHide 強制は src/platform/spawn.mjs が所有する。
30
22
  async function execClaude(claudeBin, args, opts) {
31
- const execOpts = { ...opts, windowsHide: true };
32
- if (process.platform === 'win32') {
33
- return execFileP('cmd.exe', ['/c', claudeBin, ...args], execOpts);
34
- }
35
- return execFileP(claudeBin, args, execOpts);
23
+ return execFileWindowsSafe(claudeBin, args, opts);
36
24
  }
37
25
 
38
26
  export class McpInvestigationError extends Error {
@@ -257,36 +245,11 @@ export function splitArgs(s) {
257
245
  return out;
258
246
  }
259
247
 
260
- // On Windows, npm-global CLI tools (e.g. `claude-mermaid`) ship as `<name>.cmd`
261
- // batch wrappers. Node's `child_process.spawn` without `shell: true` calls Windows
262
- // `CreateProcess`, which only directly executes `.exe` files — it does NOT search
263
- // PATHEXT for `.cmd`/`.bat` shims when given a bare command name. So
264
- // `spawn('claude-mermaid', ...)` fails with ENOENT even though `claude-mermaid.cmd`
265
- // is on PATH.
266
- //
267
- // Until v1.2.1 this function only wrapped commands whose name literally ended in
268
- // `.cmd`/`.bat`, which missed the common case where the registered command is a
269
- // bare name (the CLI as installed). v1.2.2 routes any non-`.exe` command through
270
- // `cmd.exe /c` on Windows, which makes PATHEXT lookup apply and runs both `.cmd`
271
- // shims and bare names transparently.
272
- //
273
- // We keep absolute `.exe` paths un-wrapped because (a) they spawn correctly as-is
274
- // and (b) wrapping them through `cmd.exe /c "<path with spaces>" args` runs into
275
- // cmd.exe's quoting rules for paths containing spaces, which add risk for zero
276
- // benefit.
277
- //
278
- // We use `cmd.exe /c` explicitly rather than `spawn({ shell: true })` because the
279
- // latter triggers DEP0190 on Node 24+ and re-introduces argument-quoting risks
280
- // (matches the rationale in haiku-caller's buildSpawnArgs and the caveat
281
- // `windows-node-spawn-claude-fails-with-enoent-because-claude-is-a-cmd-wrapper`).
248
+ // Windows の cmd.exe /c wrap 規則 (.exe は直接起動) は src/platform/spawn.mjs が所有する。
249
+ // 歴史的な {cmd, cmdArgs} shape は既存呼び出し・test 契約のため維持する。
282
250
  export function buildStdioSpawn(command, args) {
283
- if (process.platform !== 'win32') {
284
- return { cmd: command, cmdArgs: args };
285
- }
286
- if (/\.exe$/i.test(command)) {
287
- return { cmd: command, cmdArgs: args };
288
- }
289
- return { cmd: 'cmd.exe', cmdArgs: ['/c', command, ...args] };
251
+ const invocation = windowsCompatibleCommand(command, args);
252
+ return { cmd: invocation.command, cmdArgs: invocation.args };
290
253
  }
291
254
 
292
255
  async function spawnAndQuery({ command, args, env = {} }, serverName) {
@@ -23,26 +23,16 @@ export class ToolDbSchemaError extends Error {
23
23
  }
24
24
  }
25
25
 
26
+ // host別のDB file名と正規化は src/host/adapters.mjs が所有する。
27
+ import { getHostAdapter } from '../host/adapters.mjs';
28
+ export { normalizeToolDbHostAgent } from '../host/adapters.mjs';
29
+
26
30
  export function globalDbPath(hostAgent = 'claude') {
27
- const host = normalizeToolDbHostAgent(hostAgent);
28
- const file = host === 'claude' ? 'tool-db.json' : `tool-db.${host}.json`;
29
- return join(homedir(), '.spotter', file);
31
+ return join(homedir(), '.spotter', getHostAdapter(hostAgent).toolDbFileName);
30
32
  }
31
33
 
32
34
  export function localDbPath(projectRoot, hostAgent = 'claude') {
33
- const host = normalizeToolDbHostAgent(hostAgent);
34
- const file = host === 'claude' ? 'tool-db.json' : `tool-db.${host}.json`;
35
- return join(projectRoot, '.spotter', file);
36
- }
37
-
38
- export function normalizeToolDbHostAgent(hostAgent = 'claude') {
39
- if (hostAgent === undefined || hostAgent === null || hostAgent === '') {
40
- return 'claude';
41
- }
42
- if (hostAgent === 'claude' || hostAgent === 'codex' || hostAgent === 'automation') {
43
- return hostAgent;
44
- }
45
- throw new TypeError(`tool-db hostAgent must be claude, codex, or automation; got ${hostAgent}`);
35
+ return join(projectRoot, '.spotter', getHostAdapter(hostAgent).toolDbFileName);
46
36
  }
47
37
 
48
38
  // Load a DB file. Missing file → returns empty db (this is normal, not an error).
@@ -87,25 +87,10 @@ async function readMcpServersFile(path) {
87
87
  return (data.mcpServers && typeof data.mcpServers === 'object') ? data.mcpServers : {};
88
88
  }
89
89
 
90
- // Normalize a project path for matching against `~/.claude.json` `projects[]` keys.
91
- // Claude stores absolute paths verbatim, but representation can drift across:
92
- // - separator: `\` on Windows vs `/`
93
- // - drive-letter case: `C:\` vs `c:\` on Windows
94
- // - trailing slash: `/foo/bar` vs `/foo/bar/`
95
- // On Windows we canonicalize to forward slashes and lower-case (case-insensitive
96
- // filesystem). On POSIX, `\` is a legal filename character — collapsing it to `/`
97
- // would conflate genuinely distinct paths (e.g. literal `C:\Users\u\proj` vs the
98
- // hypothetical POSIX path `C:/Users/u/proj`), and case stays significant. POSIX
99
- // only normalizes the trailing slash.
100
- export function normalizeProjectPath(p) {
101
- if (typeof p !== 'string' || p.length === 0) return '';
102
- let s = p;
103
- if (process.platform === 'win32') {
104
- s = s.replace(/\\/g, '/').toLowerCase();
105
- }
106
- s = s.replace(/\/+$/, '');
107
- return s;
108
- }
90
+ // OS依存のパス表記正規化は src/platform/paths.mjs が所有する。
91
+ // `~/.claude.json` `projects[]` キー照合の用途と正規化ルールの根拠はそちらを参照。
92
+ import { normalizeProjectPath } from '../platform/paths.mjs';
93
+ export { normalizeProjectPath };
109
94
 
110
95
  // Extract user-scope `mcpServers` from a parsed `~/.claude.json` object.
111
96
  export function extractUserScopeServers(claudeJson) {
@@ -11,76 +11,13 @@
11
11
  // sub-agents.
12
12
 
13
13
  import { resolveAll } from './lookup.mjs';
14
- import { listMcpToolsAll, listMcpServers, bellVisibleName } from './investigate-mcp.mjs';
15
- import { getClaudeAiBaselineByServer } from './claude-ai-baseline.mjs';
16
- import { buildCodexInvestigationSnapshot } from './investigate-codex.mjs';
17
- import { listSkillsAll } from './investigate-skills.mjs';
18
- import { listAgentsAll } from './investigate-agents.mjs';
19
- import { localDbPath, globalDbPath, normalizeToolDbHostAgent } from './loader.mjs';
14
+ import { getHostAdapter } from '../host/adapters.mjs';
15
+ import { localDbPath, globalDbPath } from './loader.mjs';
20
16
 
21
- // Pure filter: returns the subset of the claude.ai baseline whose server name is
22
- // present in `presentServerNames`. Accepts a Set for O(1) membership. Extracted as
23
- // a named export so it can be unit-tested without a live `claude` CLI.
24
- export function filterClaudeAiBaseline(presentServerNames) {
25
- const out = new Map();
26
- for (const [serverName, tools] of getClaudeAiBaselineByServer()) {
27
- if (!presentServerNames.has(serverName)) continue;
28
- for (const [toolName, description] of Object.entries(tools)) {
29
- out.set(toolName, description);
30
- }
31
- }
32
- return out;
33
- }
34
-
35
- // Build the (name → description) map for an investigation pass across all sources:
36
- // - claude.ai MCP baseline (Gmail / Calendar / Drive — OAuth, not locally introspectable)
37
- // - MCP servers via stdio + HTTP/SSE (user + project .mcp.json, live fetched)
38
- // - Skills from user scope, project scope, and enabled plugins
39
- // - Sub-agents from user scope, project scope, and enabled plugins
40
- //
41
- // Returns an in-memory snapshot the caller can use as the investigate() backend.
42
- export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
43
- const snapshot = new Map();
44
-
45
- // Anthropic-provided `claude.ai ...` MCP servers. Hardcoded because the OAuth proxy
46
- // is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
47
- // Injected only for servers actually present in `claude mcp list` — otherwise phantom
48
- // tools (Gmail/Calendar/Drive) leak into environments where those servers are not
49
- // connected. See filterClaudeAiBaseline above.
50
- const servers = await listMcpServers({ claudeBin, projectRoot });
51
- const presentServerNames = new Set(servers.map((s) => s.name));
52
- const baseline = filterClaudeAiBaseline(presentServerNames);
53
- for (const [name, description] of baseline) {
54
- snapshot.set(name, description);
55
- }
56
- if (baseline.size > 0) {
57
- logFn(`claude.ai baseline injected: ${baseline.size} tools from ${[...presentServerNames].filter((n) => n.startsWith('claude.ai ')).join(', ')}`);
58
- }
59
-
60
- // MCP servers (stdio + user-registered HTTP/SSE). projectRoot forwards for
61
- // project-scope `.mcp.json` merge (see mcp-config.mjs).
62
- const mcp = await listMcpToolsAll({ logFn, claudeBin, projectRoot });
63
- for (const [serverName, tools] of mcp.entries()) {
64
- for (const tool of tools) {
65
- if (!tool.description || tool.description.length === 0) continue;
66
- snapshot.set(bellVisibleName(serverName, tool.name), tool.description);
67
- }
68
- }
69
-
70
- // Skills (plugin-namespaced as `<plugin>:<name>`, or bare for user/project scope).
71
- const skills = await listSkillsAll({ logFn, projectRoot });
72
- for (const [name, description] of skills) {
73
- snapshot.set(name, description);
74
- }
75
-
76
- // Sub-agents (bare name; project > user > plugin precedence resolved internally).
77
- const agents = await listAgentsAll({ logFn, projectRoot });
78
- for (const [name, description] of agents) {
79
- snapshot.set(name, description);
80
- }
81
-
82
- return snapshot;
83
- }
17
+ // ベンダー別のsnapshot builderと実装本体は investigate-claude.mjs /
18
+ // investigate-codex.mjs へ移設し、選択は src/host/adapters.mjs が所有する。
19
+ // 既存importer・test契約のため同名で再exportする。
20
+ export { buildInvestigationSnapshot, filterClaudeAiBaseline } from './investigate-claude.mjs';
84
21
 
85
22
  // Refresh the tool-db. Discovers all currently available tools, resolves each via the
86
23
  // 3-tier lookup, writes through. Returns the resolved Map.
@@ -91,17 +28,15 @@ export async function refresh({
91
28
  codexBin = 'codex',
92
29
  hostAgent = 'claude',
93
30
  } = {}) {
94
- const toolDbHostAgent = normalizeToolDbHostAgent(hostAgent);
95
- const snapshot = toolDbHostAgent === 'codex'
96
- ? await buildCodexInvestigationSnapshot({ logFn, codexBin, projectRoot })
97
- : await buildInvestigationSnapshot({ logFn, claudeBin, projectRoot });
31
+ const adapter = getHostAdapter(hostAgent);
32
+ const snapshot = await adapter.buildSnapshot({ logFn, claudeBin, codexBin, projectRoot });
98
33
  const toolNames = Array.from(snapshot.keys());
99
34
  const investigate = async (name) => snapshot.get(name) ?? null;
100
35
 
101
36
  return resolveAll({
102
37
  toolNames,
103
- localPath: localDbPath(projectRoot, toolDbHostAgent),
104
- globalPath: globalDbPath(toolDbHostAgent),
38
+ localPath: localDbPath(projectRoot, adapter.hostAgent),
39
+ globalPath: globalDbPath(adapter.hostAgent),
105
40
  investigate,
106
41
  logFn,
107
42
  });