diff --git a/docs/reflections/deep/the-blank-screen-saga-pr15-dynamic-imports-2026-03-30.md b/docs/reflections/deep/the-blank-screen-saga-pr15-dynamic-imports-2026-03-30.md new file mode 100644 index 000000000..8b20de626 --- /dev/null +++ b/docs/reflections/deep/the-blank-screen-saga-pr15-dynamic-imports-2026-03-30.md @@ -0,0 +1,128 @@ +--- +story_type: saga +emotional_arc: "confusion → investigation → discovery → solution → understanding" +codex_terms: [7, 15, 22, 33] +date: 2026-03-30 +duration: "2 hours" +--- + +# The Blank Screen: A Tale of Dynamic Imports and Missing Public Files + +## The Moment of Discovery + +The TUI loaded, but nothing appeared on the screen. + +I watched as the framework initialized—boot sequence logged cleanly, agents loaded, skills registered. Everything looked perfect. And then: nothing. A blank screen where the agent dropdown should have been, where the command palette should have responded to every keystroke. + +This is the kind of bug that makes you question everything. Not a crash, not an error message—just absence. The system was running, but something fundamental had broken in the space between "ready" and "rendering." + +## The Investigation Begins + +I started with the obvious culprit: the plugin. The StrRay Codex Plugin was supposed to inject framework context into the system prompt, but something was going wrong during load. The error wasn't showing up anywhere obvious—no stack trace, no warning, nothing in the logs. + +That's when I found it. Buried in the plugin initialization code was a static import: + +```typescript +import { frameworkLogger } from "../core/framework-logger.js"; +``` + +Simple. Clean. And completely wrong. + +## The Path Resolution Problem + +Here's what happens when your plugin lives in `.opencode/plugins/strray-codex-injection.js` and tries to import from `../core/framework-logger.js`: + +The import resolves relative to the plugin's location. So it looks for: +- `.opencode/plugins/../core/framework-logger.js` +- Which means: `.opencode/core/framework-logger.js` + +But the framework-logger is actually in: +- `node_modules/strray-ai/dist/core/framework-logger.js` + +The paths don't match. The import fails. And because it's happening in plugin initialization—before most of the framework has booted—the error gets swallowed by some defensive try-catch somewhere, leaving us with nothing but a blank screen and the faint smell of failure. + +This is the hazard of static imports in plugin architectures. In development, when everything runs from `src/` or `dist/`, the relative paths work fine. But in consumer installations—when someone installs `strray-ai` as an npm package—the plugin gets copied to `.opencode/plugins/`, and suddenly those relative paths are pointing at nothing. + +## The Candidate Pattern + +The fix came from looking at how other loaders in the codebase solved this exact problem. There's a pattern I call "candidate paths"—instead of one fixed import path, you try several: + +```typescript +async function loadFrameworkLogger() { + if (_frameworkLogger) return _frameworkLogger; + const candidates = [ + "../core/framework-logger.js", // dev: from plugin/ + "../../dist/core/framework-logger.js", // dev: from dist/plugin/ + "../../node_modules/strray-ai/dist/core/framework-logger.js", // consumer + ]; + for (const p of candidates) { + try { + const mod = await import(p); + _frameworkLogger = mod.frameworkLogger; + return _frameworkLogger; + } catch (_) { + // try next candidate + } + } + // Fallback: no-op logger so plugin doesn't crash + _frameworkLogger = { + log: (_module: string, _event: string, _status: string, _data?: any) => {}, + }; + return _frameworkLogger; +} +``` + +It's not elegant. It's not beautiful. But it works across every environment the plugin might find itself in: development, production, consumer install, and whatever weird hybrid states emerge from npm linking. + +## Meanwhile, at the Server + +While I was wrestling with imports, another bug was quietly making itself known. The CLI server—when you run `npx strray-ai server`—was supposed to serve the web interface. But the `PUBLIC_DIR` was pointing to `dist/public/`, which doesn't exist at runtime unless you've run the build. + +In development, this wasn't a problem because I was always running from source. But in consumer installations, after `npm install strray-ai`, there's no `dist/` directory at the project root. The server would start, try to serve static files from a non-existent directory, and fail silently (well, silently enough that you might not notice until you tried to load the page). + +The fix was simpler here: point `PUBLIC_DIR` to the actual location where files exist: + +```typescript +const ROOT_DIR = join(__dirname, "..", ".."); +const PUBLIC_DIR = join(ROOT_DIR, "public"); // Changed from "dist/public" +``` + +And update the build script to copy `public/` into `dist/public/` so both paths work: + +```json +"build": "tsc && mkdir -p dist/public && cp -r public/* dist/public/ && ..." +``` + +This way, before publishing, we ensure the static files exist in both locations. After publishing, the runtime falls back to `public/` at the root. + +## The Error Handler Detour + +While fixing the server, I noticed the error handler middleware was in the wrong place. Express middleware order matters—things get executed in the order you register them. The error handler was registered *before* some of the routes, which meant errors in those routes might not get handled properly. + +```typescript +// Wrong: error handler too early +app.use(errorHandler); // This catches errors from... nowhere? +app.use(routes); + +// Correct: error handler at the end +app.use(routes); +app.use(errorHandler); // This catches everything above it +``` + +This is one of those bugs that manifests only in specific error conditions, which makes it extra insidious. Everything works fine until something actually goes wrong, and then the error handling is inconsistent. + +## The Pattern That Emerges + +What strikes me about these three bugs—static imports, missing public directory, misplaced middleware—is that they're all about *location*. Where things are, where they're expected to be, and where they actually end up. + +In a monorepo, everything is relative to the monorepo root. In an npm package, everything is relative to `node_modules/`. In a plugin, everything is relative to the plugin's location. These different perspectives create friction, and that friction manifests as bugs that only appear in certain environments. + +The candidate path pattern isn't just a hack—it's a recognition that location matters, that relative imports are fragile, and that the solution is to be explicit about the places we expect to find things. + +## What Next + +This fix went into commit `fd289e4` and was part of the v1.15.32 release. But the underlying pattern—candidate paths for dynamic imports—needs to be applied consistently across the codebase. There are likely other places where static imports will break in consumer environments. + +The blank screen taught me something: sometimes the most serious bugs announce themselves with silence. No error message, no stack trace, just absence. The system is running, but it's not quite alive. + +Next time I see a blank screen, I'll know where to look first. diff --git a/src/core/bridge.mjs b/src/core/bridge.mjs index a5357e9da..63e791f8c 100644 --- a/src/core/bridge.mjs +++ b/src/core/bridge.mjs @@ -40,10 +40,15 @@ import { existsSync, readFileSync, + writeFileSync, appendFileSync, mkdirSync, + symlinkSync, + unlinkSync, + renameSync, + lstatSync, } from "fs"; -import { join, dirname, resolve } from "path"; +import { join, dirname, resolve, relative } from "path"; import { fileURLToPath } from "url"; import { homedir } from "os"; import { createServer } from "http"; @@ -374,6 +379,356 @@ async function handleStats() { }; } +// ============================================================================ +// Quality Gate Helper (bridge-native) +// ============================================================================ + +/** + * Run quality gate check using the loaded QualityGateModule. + * Falls back to { passed: true } when module is not available. + */ +async function runQualityGateCheck(context, projectRoot, logDir) { + if (!QualityGateModule || !QualityGateModule.runQualityGate) { + return { passed: true, violations: [], note: "quality-gate module not available" }; + } + + try { + const result = await QualityGateModule.runQualityGate(context); + return { + passed: result.passed, + violations: result.violations, + checks: result.checks, + }; + } catch (e) { + logToActivity(logDir, `quality-gate error: ${e.message}`); + return { passed: true, violations: [], error: e.message }; + } +} + +// ============================================================================ +// Additional Command Handlers +// ============================================================================ + +/** + * Validate one or more files using quality gate checks. + */ +async function handleValidate(input, projectRoot, logDir) { + const { files, operation } = input; + logToActivity(logDir, `validate: files=${JSON.stringify(files || [])} operation=${operation}`); + + const fileArray = Array.isArray(files) ? files : (files ? [files] : []); + if (fileArray.length === 0) { + return { error: "validate requires a 'files' array" }; + } + + const results = []; + for (const filePath of fileArray) { + const qualityResult = await runQualityGateCheck( + { tool: "write", args: { filePath } }, + projectRoot, + logDir, + ); + results.push({ file: filePath, ...qualityResult }); + } + + const allPassed = results.every((r) => r.passed); + return { + passed: allPassed, + operation: operation || "validate", + fileResults: results, + }; +} + +/** + * Check code against codex rules — quality gate + optional deep enforcement. + */ +async function handleCodexCheck(input, projectRoot, logDir) { + const { code, focusAreas, operation } = input; + const codeLen = code?.length || 0; + logToActivity(logDir, `codex-check: code_length=${codeLen} focus=${focusAreas} operation=${operation}`); + + const allViolations = []; + const allChecks = []; + let enforcerRan = false; + + // Phase 1: Quality gate (fast meta-checks + basic patterns) + const qualityResult = await runQualityGateCheck( + { tool: "write", args: { content: code } }, + projectRoot, + logDir, + ); + + if (qualityResult.checks) { + allChecks.push(...qualityResult.checks); + } + if (qualityResult.violations?.length) { + allViolations.push(...qualityResult.violations); + } + + // Phase 2: Enforcement validators (deep code analysis — security, quality, architecture) + // Only run content-analysis validators — skip project-level validators that + // require full context (docs, tests, CI, package.json) and always fail on snippets. + const SNIPPET_SAFE_RULES = new Set([ + "security-by-design", + "input-validation", + "clean-debug-logs", + "console-log-usage", + "no-duplicate-code", + "loop-safety", + "no-over-engineering", + "single-responsibility", + "error-resolution", + "module-system-consistency", + ]); + + // Try to load ValidatorRegistry from enforcement/index.js + let enforcerValidators = null; + if (codeLen > 0) { + const distDirs = [ + join(projectRoot, "dist"), + join(projectRoot, "node_modules", "strray-ai", "dist"), + ]; + + for (const distDir of distDirs) { + try { + const rePath = join(distDir, "enforcement", "index.js"); + if (existsSync(rePath)) { + const reModule = await import(rePath); + const ValidatorRegistry = reModule.ValidatorRegistry; + if (ValidatorRegistry) { + enforcerValidators = new ValidatorRegistry(); + } + } + } catch (e) { + // Skip — enforcer not available + } + if (enforcerValidators) break; + } + } + + if (enforcerValidators && codeLen > 0) { + try { + const ctx = { operation: "write", newCode: code, files: [] }; + const validators = enforcerValidators.getAllValidators(); + let enforcerViolations = 0; + + for (const v of validators) { + if (!SNIPPET_SAFE_RULES.has(v.ruleId)) { + if (focusAreas && focusAreas !== "all" && Array.isArray(focusAreas)) { + if (focusAreas.includes(v.category)) { + // Fall through to validate + } else { + continue; + } + } else { + continue; + } + } + + try { + const result = await v.validate(ctx); + if (!result.passed) { + enforcerViolations++; + allViolations.push({ + id: v.ruleId, + severity: v.severity || "error", + message: result.message, + suggestions: result.suggestions, + }); + allChecks.push({ id: v.ruleId, passed: false, message: result.message }); + } + } catch { + // Skip broken validators gracefully + } + } + + enforcerRan = true; + logToActivity(logDir, `codex-check: Validators ran against ${validators.length} rules (${SNIPPET_SAFE_RULES.size} snippet-safe), ${enforcerViolations} violations`); + } catch (e) { + logToActivity(logDir, `codex-check: Validator error: ${e.message}`); + } + } else if (!enforcerValidators) { + logToActivity(logDir, `codex-check: Validators not available, quality gate only`); + } + + const passed = allViolations.length === 0; + + return { + passed, + violations: allViolations, + checks: allChecks, + focusAreas: focusAreas || "all", + enforcerRan, + }; +} + +/** + * Run quality gate check on a tool+args before execution. + */ +async function handlePreProcess(input, projectRoot, logDir) { + const { tool, args } = input; + const startTime = Date.now(); + + logToActivity(logDir, `pre-process: tool=${tool}`); + + // Quality gate check + const qualityResult = await runQualityGateCheck( + { tool, args: args || {} }, + projectRoot, + logDir, + ); + + const duration = Date.now() - startTime; + logToActivity(logDir, `pre-process: complete duration=${duration}ms quality=${qualityResult.passed}`); + + return { + passed: qualityResult.passed, + duration, + qualityGate: qualityResult, + }; +} + +/** + * Post-process stub — ProcessorManager not available in standalone bridge. + */ +async function handlePostProcess(input, projectRoot, logDir) { + logToActivity(logDir, `post-process: stub (no ProcessorManager)`); + return { ran: false, reason: "post-processors not available in standalone bridge" }; +} + +/** + * Manage git hooks (install, uninstall, list, status). + * Pure filesystem operations — no framework deps needed. + */ +function handleHooks(input, projectRoot) { + const { action, hooks } = input; + const hookTypes = hooks || ["pre-commit", "post-commit", "pre-push", "post-push"]; + const gitHooksDir = join(projectRoot, ".git", "hooks"); + const strrayHooksDir = join(projectRoot, "hooks"); + + if (!existsSync(gitHooksDir)) { + return { error: "Not a git repository — no .git/hooks directory" }; + } + + const result = { managed: [], missing: [], external: [], stale: [] }; + + // ── list / status ─────────────────────────────────────── + if (action === "list" || action === "status") { + for (const hookName of hookTypes) { + const gitHook = join(gitHooksDir, hookName); + const strrayHook = join(strrayHooksDir, hookName); + + if (!existsSync(gitHook)) { + result.missing.push(hookName); + } else { + try { + const content = readFileSync(gitHook, "utf-8"); + if (content.includes("StringRay") || content.includes("strray") || content.includes("run-hook.js")) { + result.managed.push(hookName); + } else { + result.external.push(hookName); + } + } catch { + result.external.push(hookName); + } + } + + // Check if strray source hook exists + if (!existsSync(strrayHook)) { + result.stale.push(hookName); + } + } + + return { + status: "ok", + action, + hooks: result, + gitHooksDir, + strrayHooksDir, + }; + } + + // ── install ───────────────────────────────────────────── + if (action === "install") { + const installed = []; + const skipped = []; + const errors = []; + + for (const hookName of hookTypes) { + const src = join(strrayHooksDir, hookName); + const dst = join(gitHooksDir, hookName); + + if (!existsSync(src)) { + skipped.push(hookName); + continue; + } + + try { + // Backup existing non-strray hooks + if (existsSync(dst)) { + const content = readFileSync(dst, "utf-8"); + if (!content.includes("StringRay") && !content.includes("strray") && !content.includes("run-hook.js")) { + renameSync(dst, `${dst}.strray-backup`); + } else { + unlinkSync(dst); + } + } + + // Create symlink + const rel = relative(join(gitHooksDir), src); + try { + symlinkSync(rel, dst); + } catch { + // Symlink may fail (permissions, cross-device) — copy instead + const srcContent = readFileSync(src, "utf-8"); + writeFileSync(dst, srcContent, { mode: 0o755 }); + } + installed.push(hookName); + } catch (err) { + errors.push({ hook: hookName, error: err.message }); + } + } + + return { status: "ok", action: "install", installed, skipped, errors }; + } + + // ── uninstall ─────────────────────────────────────────── + if (action === "uninstall") { + const removed = []; + const restored = []; + + for (const hookName of hookTypes) { + const dst = join(gitHooksDir, hookName); + const backup = `${dst}.strray-backup`; + + if (!existsSync(dst)) continue; + + try { + const content = readFileSync(dst, "utf-8"); + const isStrray = content.includes("StringRay") || content.includes("strray") || content.includes("run-hook.js"); + + if (isStrray || lstatSync(dst).isSymbolicLink()) { + unlinkSync(dst); + + // Restore backup if exists + if (existsSync(backup)) { + renameSync(backup, dst); + restored.push(hookName); + } else { + removed.push(hookName); + } + } + } catch { + // Skip unremovable hooks + } + } + + return { status: "ok", action: "uninstall", removed, restored }; + } + + return { error: `Unknown hooks action: ${action}. Use: install, uninstall, list, status` }; +} + // ============================================================================ // Known Commands // ============================================================================ @@ -490,6 +845,16 @@ async function dispatchCommand(command, projectRoot, logDir) { return await handleGetCodexPrompt(command, projectRoot, logDir); case "get-config": return await handleGetConfig(command, projectRoot, logDir); + case "validate": + return await handleValidate(command, projectRoot, logDir); + case "codex-check": + return await handleCodexCheck(command, projectRoot, logDir); + case "pre-process": + return await handlePreProcess(command, projectRoot, logDir); + case "post-process": + return await handlePostProcess(command, projectRoot, logDir); + case "hooks": + return handleHooks(command, projectRoot); case "stats": return handleStats(); default: diff --git a/src/integrations/hermes-agent/bridge.mjs b/src/integrations/hermes-agent/bridge.mjs index cabda2137..1a97635f3 100644 --- a/src/integrations/hermes-agent/bridge.mjs +++ b/src/integrations/hermes-agent/bridge.mjs @@ -222,7 +222,7 @@ async function runQualityGateCheck(context, projectRoot, logDir) { checks: result.checks, }; } catch (e) { - return { passed: true, violations: [], error: e.message }; + return { passed: false, violations: [{ id: "quality-gate-error", severity: "error", message: `Quality gate failed: ${e.message}` }], error: e.message }; } } diff --git a/src/integrations/hermes-agent/plugin.yaml b/src/integrations/hermes-agent/plugin.yaml index a418374ef..b4f22f009 100644 --- a/src/integrations/hermes-agent/plugin.yaml +++ b/src/integrations/hermes-agent/plugin.yaml @@ -1,5 +1,5 @@ name: strray-hermes -version: 2.1.0 +version: 2.2.0 description: StringRay AI integration plugin — auto-enforcement hooks, quality gates, git hooks, and tool awareness for Hermes Agent author: StringRay AI provides_tools: diff --git a/src/integrations/hermes-agent/test_plugin.py b/src/integrations/hermes-agent/test_plugin.py index 503017a08..6d02e9201 100644 --- a/src/integrations/hermes-agent/test_plugin.py +++ b/src/integrations/hermes-agent/test_plugin.py @@ -120,7 +120,7 @@ class TestBridgeHelper(unittest.TestCase): def test_successful_bridge_call(self): with patch("subprocess.run") as m: m.return_value = MagicMock(returncode=0, stdout='{"status":"ok"}', stderr="") - r = tools_mod._call_bridge({"command": "health"}) + r = tools_mod._bridge_call({"command": "health"}) self.assertEqual(r["status"], "ok") # Should call node with bridge path self.assertIn("node", m.call_args[0][0]) @@ -129,24 +129,24 @@ def test_successful_bridge_call(self): def test_bridge_returns_error(self): with patch("subprocess.run") as m: m.return_value = MagicMock(returncode=1, stdout="", stderr="module not found") - r = tools_mod._call_bridge({"command": "health"}) + r = tools_mod._bridge_call({"command": "health"}) self.assertIn("error", r) def test_bridge_node_not_found(self): with patch("subprocess.run", side_effect=FileNotFoundError): - r = tools_mod._call_bridge({"command": "health"}) + r = tools_mod._bridge_call({"command": "health"}) self.assertEqual(r["error"], "node not found") def test_bridge_timeout(self): with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("c", 10)): - r = tools_mod._call_bridge({"command": "health"}, timeout=10) + r = tools_mod._bridge_call({"command": "health"}, timeout=10) self.assertIn("timed out", r["error"]) class TestStrrayHealth(unittest.TestCase): def test_health_via_bridge(self): """v2: health uses bridge first.""" - with patch.object(tools_mod, "_call_bridge", return_value={"status": "ok", "framework": "loaded", "version": "1.15.0", "components": {}}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"status": "ok", "framework": "loaded", "version": "1.15.0", "components": {}}) as m: r = json.loads(tools_mod.strray_health({})) self.assertEqual(r["status"], "ok") self.assertEqual(r["via"], "bridge") @@ -154,13 +154,13 @@ def test_health_via_bridge(self): def test_health_fallback_to_cli(self): """v2: falls back to CLI when bridge fails.""" - with patch.object(tools_mod, "_call_bridge", return_value={"error": "node not found"}): + with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}): with patch.object(tools_mod, "_run_strray", return_value='{"status":"ok","output":"healthy"}') as cli: r = json.loads(tools_mod.strray_health({})) cli.assert_called_once() def test_health_ignores_extra_args(self): - with patch.object(tools_mod, "_call_bridge", return_value={"status": "ok", "framework": "loaded", "version": "1.0", "components": {}}): + with patch.object(tools_mod, "_bridge_call", return_value={"status": "ok", "framework": "loaded", "version": "1.0", "components": {}}): r = json.loads(tools_mod.strray_health({"x": 1})) self.assertEqual(r["status"], "ok") @@ -168,7 +168,7 @@ def test_health_ignores_extra_args(self): class TestStrrayValidate(unittest.TestCase): def test_with_files_via_bridge(self): """v2: validate uses bridge first.""" - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "fileResults": []}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}) as m: r = json.loads(tools_mod.strray_validate({"files": ["a.ts", "b.ts"], "operation": "commit"})) self.assertEqual(r["status"], "passed") self.assertEqual(r["files_checked"], 2) @@ -176,13 +176,13 @@ def test_with_files_via_bridge(self): m.assert_called_once() def test_bridge_violations(self): - with patch.object(tools_mod, "_call_bridge", return_value={"passed": False, "fileResults": [{"file": "a.ts", "passed": False, "violations": ["tests-required"]}]}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"passed": False, "fileResults": [{"file": "a.ts", "passed": False, "violations": ["tests-required"]}]}) as m: r = json.loads(tools_mod.strray_validate({"files": ["a.ts"]})) self.assertEqual(r["status"], "violations") self.assertEqual(r["file_results"][0]["violations"], ["tests-required"]) def test_bridge_error_fallback_to_cli(self): - with patch.object(tools_mod, "_call_bridge", return_value={"error": "node not found"}): + with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}): with patch.object(tools_mod, "_run_strray", return_value='{"status":"ok","output":"valid"}') as cli: r = json.loads(tools_mod.strray_validate({"files": ["a.ts"]})) self.assertEqual(r["via"], "cli") @@ -197,12 +197,12 @@ def test_no_files_key_error(self): self.assertIn("error", r) def test_default_operation(self): - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "fileResults": []}): + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}): r = json.loads(tools_mod.strray_validate({"files": ["a.ts"]})) self.assertEqual(r["operation"], "commit") def test_100_files(self): - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "fileResults": []}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}) as m: fs = [f"f{i}.ts" for i in range(100)] r = json.loads(tools_mod.strray_validate({"files": fs})) self.assertEqual(r["files_checked"], 100) @@ -211,51 +211,51 @@ def test_100_files(self): class TestStrrayCodexCheck(unittest.TestCase): def test_with_code_via_bridge(self): """v2: codex check uses bridge for real quality gate analysis.""" - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "violations": [], "checks": []}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m: r = json.loads(tools_mod.strray_codex_check({"code": "const x = null;", "operation": "create"})) self.assertEqual(r["status"], "passed") self.assertEqual(r["via"], "bridge") m.assert_called_once() def test_with_code_bridge_violations(self): - with patch.object(tools_mod, "_call_bridge", return_value={"passed": False, "violations": ["console.log found"], "checks": []}): + with patch.object(tools_mod, "_bridge_call", return_value={"passed": False, "violations": ["console.log found"], "checks": []}): r = json.loads(tools_mod.strray_codex_check({"code": "console.log(x)", "operation": "create"})) self.assertEqual(r["status"], "violations") self.assertEqual(r["violations"], ["console.log found"]) def test_with_focus_areas(self): - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "violations": [], "checks": []}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m: tools_mod.strray_codex_check({"code": "eval()", "operation": "modify", "focus_areas": ["security"]}) self.assertEqual(m.call_args[0][0]["focusAreas"], ["security"]) def test_empty_string_code_treated_as_code(self): """BUG FIX: empty string '' should still be treated as code (is not None).""" - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "violations": [], "checks": []}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m: r = json.loads(tools_mod.strray_codex_check({"code": "", "operation": "create"})) self.assertEqual(r["status"], "passed") self.assertEqual(r["code_length"], 0) def test_no_code_bridge_health(self): """No code provided — bridge health check.""" - with patch.object(tools_mod, "_call_bridge", return_value={"framework": "loaded", "version": "1.15.0", "components": {}}) as m: + with patch.object(tools_mod, "_bridge_call", return_value={"framework": "loaded", "version": "1.15.0", "components": {}}) as m: r = json.loads(tools_mod.strray_codex_check({"operation": "refactor"})) self.assertEqual(r["status"], "ok") self.assertIn("Pass", r["note"]) def test_no_code_bridge_error_fallback(self): - with patch.object(tools_mod, "_call_bridge", return_value={"error": "node not found"}): + with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}): with patch.object(tools_mod, "_run_strray", return_value='{"status":"ok","output":"healthy"}') as cli: r = json.loads(tools_mod.strray_codex_check({"operation": "create"})) cli.assert_called_once() def test_default_operation(self): - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "violations": [], "checks": []}): + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}): r = json.loads(tools_mod.strray_codex_check({"code": "x"})) self.assertEqual(r["operation"], "create") def test_multiline_code(self): code = "function foo() {\n return null;\n}\n" - with patch.object(tools_mod, "_call_bridge", return_value={"passed": True, "violations": [], "checks": []}): + with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}): r = json.loads(tools_mod.strray_codex_check({"code": code, "operation": "create"})) self.assertEqual(r["code_length"], len(code)) @@ -610,7 +610,7 @@ def test_bridge_health(self): if not bridge_path.exists(): self.skipTest("bridge.mjs not built yet") - r = tools_mod._call_bridge({"command": "health"}, timeout=10) + r = tools_mod._bridge_call({"command": "health"}, timeout=10) self.assertNotIn("error", r) self.assertIn("status", r) @@ -619,7 +619,7 @@ def test_bridge_stats(self): if not bridge_path.exists(): self.skipTest("bridge.mjs not built yet") - r = tools_mod._call_bridge({"command": "stats"}, timeout=5) + r = tools_mod._bridge_call({"command": "stats"}, timeout=5) self.assertIn("frameworkReady", r) def test_bridge_quality_gate(self): @@ -628,7 +628,7 @@ def test_bridge_quality_gate(self): self.skipTest("bridge.mjs not built yet") # Clean code should pass - r = tools_mod._call_bridge({ + r = tools_mod._bridge_call({ "command": "codex-check", "code": "const x: number = 42;", }, timeout=10) @@ -641,7 +641,7 @@ def test_bridge_quality_gate_violation(self): self.skipTest("bridge.mjs not built yet") # Code with console.log should fail - r = tools_mod._call_bridge({ + r = tools_mod._bridge_call({ "command": "codex-check", "code": "console.log('hello');", }, timeout=10) @@ -727,16 +727,16 @@ class TestBridgeErrorPaths(unittest.TestCase): """Cover remaining bridge error branches in tools.py.""" def test_bridge_json_decode_error(self): - """_call_bridge with non-JSON stdout returns error.""" + """_bridge_call with non-JSON stdout returns error.""" with patch("subprocess.run") as m: m.return_value = MagicMock(returncode=0, stdout="not json", stderr="") - r = tools_mod._call_bridge({"command": "health"}) + r = tools_mod._bridge_call({"command": "health"}) self.assertIn("error", r) def test_bridge_os_error(self): - """_call_bridge with OSError during subprocess returns error.""" + """_bridge_call with OSError during subprocess returns error.""" with patch("subprocess.run", side_effect=OSError("broken pipe")): - r = tools_mod._call_bridge({"command": "health"}) + r = tools_mod._bridge_call({"command": "health"}) self.assertIn("error", r) def test_bridge_generic_exception_in_run_strray(self): @@ -747,7 +747,7 @@ def test_bridge_generic_exception_in_run_strray(self): def test_validate_cli_fallback_error(self): """strray_validate CLI fallback when CLI returns an error JSON.""" - with patch.object(tools_mod, "_call_bridge", return_value={"error": "bridge down"}): + with patch.object(tools_mod, "_bridge_call", return_value={"error": "bridge down"}): with patch.object(tools_mod, "_run_strray", return_value='{"error": "validation failed"}'): r = json.loads(tools_mod.strray_validate({"files": ["a.ts"]})) # CLI error path returns raw result without "via" key @@ -755,29 +755,30 @@ def test_validate_cli_fallback_error(self): def test_codex_check_static_fallback(self): """strray_codex_check with code but bridge down falls back to static analysis.""" - with patch.object(tools_mod, "_call_bridge", return_value={"error": "bridge down"}): + with patch.object(tools_mod, "_bridge_call", return_value={"error": "bridge down"}): r = json.loads(tools_mod.strray_codex_check({"code": "console.log(1)", "operation": "create"})) self.assertEqual(r["via"], "static") self.assertIn("basic analysis", r["note"]) def test_codex_check_cli_health_error(self): """strray_codex_check no-code path: bridge error + CLI also errors.""" - with patch.object(tools_mod, "_call_bridge", return_value={"error": "no node"}): + with patch.object(tools_mod, "_bridge_call", return_value={"error": "no node"}): with patch.object(tools_mod, "_run_strray", return_value='{"error": "strray-ai not found"}'): r = json.loads(tools_mod.strray_codex_check({"operation": "create"})) self.assertIn("error", r) -class TestFindProjectRoot(unittest.TestCase): - """Test the _find_project_root function in tools.py.""" +class TestGetProjectRoot(unittest.TestCase): + """Test the _get_project_root helper in tools.py.""" def test_returns_cwd_when_no_package_json(self): """With no package.json in any ancestor, falls back to cwd.""" - with patch.object(tools_mod.Path, "exists", return_value=False): - # _find_project_root is called at module level, so we call it directly - result = tools_mod._find_project_root.__wrapped__(tools_mod.PLUGIN_DIR) if hasattr(tools_mod._find_project_root, "__wrapped__") else None - # We can't easily override the module-level call, but we verify the function exists - self.assertTrue(callable(tools_mod._find_project_root)) + # _get_project_root delegates to __init__.py's PROJECT_ROOT. + # We verify the function exists and is callable. + self.assertTrue(callable(tools_mod._get_project_root)) + # Verify it returns a Path-like value + result = tools_mod._get_project_root() + self.assertIsNotNone(result) class TestPreToolCallBridgeErrors(unittest.TestCase): @@ -793,7 +794,7 @@ def test_code_tool_bridge_error_does_not_crash(self, mock_bridge): """Bridge error during pre-process should not crash the hook.""" pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1") self.assertEqual(pi._session_stats["code_operations"], 1) - # Note: bridge_calls stat is inside the real _call_bridge, + # Note: bridge_calls stat is inside the real _bridge_call, # so mocking it doesn't increment the counter. Verify hook doesn't crash. mock_bridge.assert_called_once() @@ -930,17 +931,17 @@ class TestBridgeHelperTimeoutDefault(unittest.TestCase): """Verify bridge timeout defaults.""" def test_default_timeout(self): - """_call_bridge defaults to 30s timeout.""" + """_bridge_call defaults to 30s timeout.""" with patch("subprocess.run") as m: m.return_value = MagicMock(returncode=0, stdout='{"ok":true}', stderr="") - tools_mod._call_bridge({"command": "health"}) + tools_mod._bridge_call({"command": "health"}) self.assertEqual(m.call_args[1]["timeout"], 30) def test_custom_timeout(self): - """_call_bridge respects custom timeout.""" + """_bridge_call respects custom timeout.""" with patch("subprocess.run") as m: m.return_value = MagicMock(returncode=0, stdout='{\"ok\":true}', stderr="") - tools_mod._call_bridge({"command": "health"}, timeout=5) + tools_mod._bridge_call({"command": "health"}, timeout=5) self.assertEqual(m.call_args[1]["timeout"], 5) @@ -949,7 +950,7 @@ class TestStrrayHooksTool(unittest.TestCase): def test_list_via_bridge(self): """list action uses bridge when available.""" - with patch.object(tools_mod, "_call_bridge", return_value={ + with patch.object(tools_mod, "_bridge_call", return_value={ "status": "ok", "action": "list", "hooks": {"managed": ["pre-commit"], "missing": [], "external": [], "stale": []}, }) as m: @@ -963,7 +964,7 @@ def test_list_via_bridge(self): def test_install_via_bridge(self): """install action uses bridge.""" - with patch.object(tools_mod, "_call_bridge", return_value={ + with patch.object(tools_mod, "_bridge_call", return_value={ "status": "ok", "action": "install", "installed": ["pre-commit", "post-commit"], "skipped": [], "errors": [], }) as m: @@ -973,7 +974,7 @@ def test_install_via_bridge(self): def test_uninstall_via_bridge(self): """uninstall action uses bridge.""" - with patch.object(tools_mod, "_call_bridge", return_value={ + with patch.object(tools_mod, "_bridge_call", return_value={ "status": "ok", "action": "uninstall", "removed": ["pre-commit"], "restored": [], }) as m: r = json.loads(tools_mod.strray_hooks({"action": "uninstall"})) @@ -981,14 +982,14 @@ def test_uninstall_via_bridge(self): def test_bridge_error_fallback(self): """Falls back to file-based when bridge errors.""" - with patch.object(tools_mod, "_call_bridge", return_value={"error": "node not found"}): + with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}): # Without a real git repo, should return error r = json.loads(tools_mod.strray_hooks({"action": "list"})) self.assertIn("via", r) def test_specific_hooks(self): """Can request specific hooks.""" - with patch.object(tools_mod, "_call_bridge", return_value={ + with patch.object(tools_mod, "_bridge_call", return_value={ "status": "ok", "action": "list", "hooks": {"managed": [], "missing": ["pre-commit"], "external": [], "stale": []}, }) as m: @@ -998,7 +999,7 @@ def test_specific_hooks(self): def test_status_defaults_to_list(self): """status action works like list.""" - with patch.object(tools_mod, "_call_bridge", return_value={ + with patch.object(tools_mod, "_bridge_call", return_value={ "status": "ok", "action": "status", "hooks": {"managed": [], "missing": [], "external": [], "stale": []}, }) as m: @@ -1008,7 +1009,7 @@ def test_status_defaults_to_list(self): def test_default_action_is_list(self): """Missing action defaults to list.""" - with patch.object(tools_mod, "_call_bridge", return_value={ + with patch.object(tools_mod, "_bridge_call", return_value={ "status": "ok", "action": "list", "hooks": {"managed": [], "missing": [], "external": [], "stale": []}, }) as m: diff --git a/src/integrations/hermes-agent/tools.py b/src/integrations/hermes-agent/tools.py index c460be856..df2a8ec60 100644 --- a/src/integrations/hermes-agent/tools.py +++ b/src/integrations/hermes-agent/tools.py @@ -11,45 +11,43 @@ import sys from pathlib import Path -# ── Bridge path ─────────────────────────────────────────────── +# ── Lazy imports from __init__ (avoids circular dependency) ── -PLUGIN_DIR = Path(__file__).resolve().parent -BRIDGE_PATH = PLUGIN_DIR / "bridge.mjs" +def _get_parent(): + """Return the parent package's _call_bridge and PROJECT_ROOT. + Tries relative import first, then falls back to scanning sys.modules + for a package that holds the tools module. + """ + # Fast path: normal package import + try: + from . import _call_bridge, PROJECT_ROOT + return _call_bridge, PROJECT_ROOT + except (ImportError, ValueError): + pass -def _find_project_root(): - d = PLUGIN_DIR - for _ in range(6): - if (d / "package.json").exists(): - return d - d = d.parent - return Path.cwd() + # Fallback: find any module in sys.modules that has _call_bridge + import importlib + import sys + for mod_name, mod in sys.modules.items(): + if mod is not None and hasattr(mod, '_call_bridge') and mod is not sys.modules.get(__name__): + return mod._call_bridge, mod.PROJECT_ROOT -PROJECT_ROOT = _find_project_root() + raise ImportError( + "Cannot locate parent package with _call_bridge. " + "Ensure the hermes-agent plugin is loaded as a package." + ) -# ── Bridge helper ───────────────────────────────────────────── +def _bridge_call(command, timeout=30): + """Call bridge.mjs via the plugin's shared helper (with stats tracking).""" + _call_bridge, _ = _get_parent() + return _call_bridge(command, timeout) -def _call_bridge(command: dict, timeout: int = 30) -> dict: - """Call bridge.mjs via Node.js, return parsed JSON response.""" - try: - result = subprocess.run( - ["node", str(BRIDGE_PATH), "--cwd", str(PROJECT_ROOT)], - input=json.dumps(command), - capture_output=True, - text=True, - timeout=timeout, - ) - if result.returncode != 0: - stderr = result.stderr[:300] if result.stderr else "unknown" - return {"error": stderr} - return json.loads(result.stdout) - except FileNotFoundError: - return {"error": "node not found"} - except subprocess.TimeoutExpired: - return {"error": f"timed out after {timeout}s"} - except (json.JSONDecodeError, OSError) as e: - return {"error": str(e)} + +def _get_project_root(): + _, PROJECT_ROOT = _get_parent() + return PROJECT_ROOT def _run_strray(args: list, timeout: int = 30) -> str: @@ -91,7 +89,7 @@ def strray_validate(args: dict, **kwargs) -> str: return json.dumps({"error": "No files specified for validation"}) # Try bridge first (real framework integration) - bridge_result = _call_bridge({ + bridge_result = _bridge_call({ "command": "validate", "files": files, "operation": operation, @@ -135,7 +133,7 @@ def strray_codex_check(args: dict, **kwargs) -> str: # Use 'is not None' to correctly handle empty string if code is not None: # Try bridge for real codex checking - bridge_result = _call_bridge({ + bridge_result = _bridge_call({ "command": "codex-check", "code": code, "focusAreas": focus_areas, @@ -164,7 +162,7 @@ def strray_codex_check(args: dict, **kwargs) -> str: }) # No code provided — check framework health - bridge_result = _call_bridge({"command": "health"}, timeout=10) + bridge_result = _bridge_call({"command": "health"}, timeout=10) if "error" not in bridge_result: return json.dumps({ "status": "ok", @@ -194,7 +192,7 @@ def strray_health(args: dict, **kwargs) -> str: Returns framework status, loaded components, version. """ - bridge_result = _call_bridge({"command": "health"}, timeout=10) + bridge_result = _bridge_call({"command": "health"}, timeout=10) if "error" not in bridge_result: return json.dumps({ "status": "ok", @@ -223,7 +221,7 @@ def strray_hooks(args: dict, **kwargs) -> str: hooks = args.get("hooks", ["pre-commit", "post-commit", "pre-push", "post-push"]) # Try bridge first - bridge_result = _call_bridge({ + bridge_result = _bridge_call({ "command": "hooks", "action": action, "hooks": hooks, @@ -239,8 +237,8 @@ def strray_hooks(args: dict, **kwargs) -> str: }) # Fallback: direct file-based hook management - git_hooks_dir = Path(PROJECT_ROOT) / ".git" / "hooks" - strray_hooks_dir = Path(PROJECT_ROOT) / "hooks" + git_hooks_dir = Path(_get_project_root()) / ".git" / "hooks" + strray_hooks_dir = Path(_get_project_root()) / "hooks" if not git_hooks_dir.exists(): return json.dumps({"error": "Not a git repository", "via": "fallback"}) diff --git a/src/integrations/hermes-agent/types.ts b/src/integrations/hermes-agent/types.ts index 3be540477..d27c722d8 100644 --- a/src/integrations/hermes-agent/types.ts +++ b/src/integrations/hermes-agent/types.ts @@ -4,7 +4,7 @@ * TypeScript interfaces for the Hermes Agent integration, * which bridges StringRay framework components to the Hermes CLI agent. * - * @version 2.0.0 + * @version 2.2.0 * @since 2026-03-27 */