#!/usr/bin/env node 'use strict'; const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const OBSERVE_RELATIVE_PATH = path.join('skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); const DEFAULT_TIMEOUT_MS = 9000; function getPluginRoot(options = {}) { if (options.pluginRoot && String(options.pluginRoot).trim()) { return String(options.pluginRoot).trim(); } if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) { return process.env.CLAUDE_PLUGIN_ROOT.trim(); } if (process.env.ECC_PLUGIN_ROOT && process.env.ECC_PLUGIN_ROOT.trim()) { return process.env.ECC_PLUGIN_ROOT.trim(); } return path.resolve(__dirname, '..', '..'); } function resolveTarget(rootDir, relPath) { const resolvedRoot = path.resolve(rootDir); const resolvedTarget = path.resolve(rootDir, relPath); if ( resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(resolvedRoot + path.sep) ) { throw new Error(`Path traversal rejected: ${relPath}`); } return resolvedTarget; } function toShellPath(filePath) { const normalized = String(filePath || ''); if (process.platform !== 'win32') { return normalized; } return normalized .replace(/^([A-Za-z]):[\\/]/, (_, driveLetter) => `/${driveLetter.toLowerCase()}/`) .replace(/\\/g, '/'); } function findShellBinary() { const candidates = []; if (process.env.BASH && process.env.BASH.trim()) { candidates.push(process.env.BASH.trim()); } if (process.platform === 'win32') { candidates.push('bash.exe', 'bash', 'sh'); } else { candidates.push('bash', 'sh'); } for (const candidate of candidates) { const probe = spawnSync(candidate, ['-c', ':'], { stdio: 'ignore', windowsHide: true }); if (!probe.error) { return candidate; } } return null; } function getPhaseFromHookId(hookId) { const prefix = String(hookId || process.env.ECC_HOOK_ID || '').split(':')[0]; return prefix === 'pre' || prefix === 'post' ? prefix : null; } function getTimeoutMs() { const parsed = Number.parseInt(process.env.ECC_OBSERVE_RUNNER_TIMEOUT_MS || '', 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS; } function combineStderr(stderr, message) { const prefix = typeof stderr === 'string' && stderr.length > 0 ? stderr.endsWith('\n') ? stderr : `${stderr}\n` : ''; return `${prefix}${message}\n`; } function run(raw, options = {}) { const input = typeof raw === 'string' ? raw : String(raw ?? ''); const phase = getPhaseFromHookId(options.hookId); if (!phase) { return { stderr: '[Hook] observe runner received an unsupported hook id; skipping observation', exitCode: 0 }; } const pluginRoot = getPluginRoot(options); let observePath; try { observePath = resolveTarget(pluginRoot, OBSERVE_RELATIVE_PATH); } catch (error) { return { stderr: `[Hook] observe runner path resolution failed: ${error.message}`, exitCode: 0 }; } if (!fs.existsSync(observePath)) { return { stderr: `[Hook] observe script not found: ${observePath}`, exitCode: 0 }; } const shell = findShellBinary(); if (!shell) { return { stderr: '[Hook] shell runtime unavailable; skipping continuous-learning observation', exitCode: 0 }; } const result = spawnSync(shell, [toShellPath(observePath), phase], { input, encoding: 'utf8', env: { ...process.env, CLAUDE_PLUGIN_ROOT: pluginRoot, ECC_PLUGIN_ROOT: pluginRoot }, cwd: process.cwd(), timeout: getTimeoutMs(), windowsHide: true }); const output = { exitCode: Number.isInteger(result.status) ? result.status : 0 }; if (typeof result.stdout === 'string' && result.stdout.length > 0) { output.stdout = result.stdout; } if (typeof result.stderr === 'string' && result.stderr.length > 0) { output.stderr = result.stderr; } if (result.error || result.signal || result.status === null) { const reason = result.error ? result.error.message : result.signal ? `terminated by signal ${result.signal}` : 'missing exit status'; output.stderr = combineStderr(output.stderr, `[Hook] observe runner failed: ${reason}`); output.exitCode = 0; } return output; } function emitHookResult(raw, output) { if (output && typeof output === 'object') { if (output.stderr) { process.stderr.write(String(output.stderr).endsWith('\n') ? String(output.stderr) : `${output.stderr}\n`); } if (Object.prototype.hasOwnProperty.call(output, 'stdout')) { process.stdout.write(String(output.stdout ?? '')); } else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) { process.stdout.write(raw); } return Number.isInteger(output.exitCode) ? output.exitCode : 0; } process.stdout.write(raw); return 0; } if (require.main === module) { let raw = ''; try { raw = fs.readFileSync(0, 'utf8'); } catch (_error) { raw = ''; } const output = run(raw, { hookId: process.argv[2] || process.env.ECC_HOOK_ID }); process.exit(emitHookResult(raw, output)); } module.exports = { OBSERVE_RELATIVE_PATH, findShellBinary, getPhaseFromHookId, run, toShellPath };