strray-ai 1.18.1 → 1.18.2

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.
Files changed (58) hide show
  1. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  2. package/dist/cli/commands/skill-install.js +1 -0
  3. package/dist/cli/commands/skill-install.js.map +1 -1
  4. package/dist/integrations/base/README.md +446 -0
  5. package/dist/integrations/hermes-agent/__init__.py +732 -0
  6. package/dist/integrations/hermes-agent/after-install.md +71 -0
  7. package/dist/integrations/hermes-agent/conftest.py +14 -0
  8. package/dist/integrations/hermes-agent/plugin.yaml +12 -0
  9. package/dist/integrations/hermes-agent/schemas.py +100 -0
  10. package/dist/integrations/hermes-agent/test_plugin.py +1100 -0
  11. package/dist/integrations/hermes-agent/tools.py +317 -0
  12. package/dist/integrations/openclaw/README.md +134 -0
  13. package/dist/skills/api-design/SKILL.md +37 -0
  14. package/dist/skills/architect-tools/SKILL.md +37 -0
  15. package/dist/skills/architecture-patterns/SKILL.md +37 -0
  16. package/dist/skills/auto-format/SKILL.md +37 -0
  17. package/dist/skills/backend-engineer/SKILL.md +49 -0
  18. package/dist/skills/boot-orchestrator/SKILL.md +37 -0
  19. package/dist/skills/bug-triage/SKILL.md +43 -0
  20. package/dist/skills/code-analyzer/SKILL.md +45 -0
  21. package/dist/skills/code-review/SKILL.md +52 -0
  22. package/dist/skills/content-creator/SKILL.md +38 -0
  23. package/dist/skills/database-engineer/SKILL.md +46 -0
  24. package/dist/skills/devops-engineer/SKILL.md +49 -0
  25. package/dist/skills/enforcer/SKILL.md +37 -0
  26. package/dist/skills/framework-compliance-audit/SKILL.md +37 -0
  27. package/dist/skills/frontend-engineer/SKILL.md +49 -0
  28. package/dist/skills/frontend-ui-ux-engineer/SKILL.md +41 -0
  29. package/dist/skills/git-workflow/SKILL.md +37 -0
  30. package/dist/skills/growth-strategist/SKILL.md +48 -0
  31. package/dist/skills/hermes-agent/SKILL.md +212 -0
  32. package/dist/skills/inference-improve/SKILL.md +97 -0
  33. package/dist/skills/lint/SKILL.md +37 -0
  34. package/dist/skills/log-monitor/SKILL.md +44 -0
  35. package/dist/skills/mobile-developer/SKILL.md +42 -0
  36. package/dist/skills/model-health-check/SKILL.md +37 -0
  37. package/dist/skills/multimodal-looker/SKILL.md +45 -0
  38. package/dist/skills/orchestrator/SKILL.md +37 -0
  39. package/dist/skills/performance-analysis/SKILL.md +37 -0
  40. package/dist/skills/performance-engineer/SKILL.md +41 -0
  41. package/dist/skills/performance-optimization/SKILL.md +37 -0
  42. package/dist/skills/processor-pipeline/SKILL.md +37 -0
  43. package/dist/skills/project-analysis/SKILL.md +42 -0
  44. package/dist/skills/refactoring-strategies/SKILL.md +37 -0
  45. package/dist/skills/registry.json +66 -0
  46. package/dist/skills/researcher/SKILL.md +37 -0
  47. package/dist/skills/security-audit/SKILL.md +47 -0
  48. package/dist/skills/security-scan/SKILL.md +37 -0
  49. package/dist/skills/seo-consultant/SKILL.md +43 -0
  50. package/dist/skills/session-management/SKILL.md +36 -0
  51. package/dist/skills/state-manager/SKILL.md +37 -0
  52. package/dist/skills/storyteller/SKILL.md +130 -0
  53. package/dist/skills/strategist/SKILL.md +32 -0
  54. package/dist/skills/tech-writer/SKILL.md +37 -0
  55. package/dist/skills/testing-best-practices/SKILL.md +37 -0
  56. package/dist/skills/testing-strategy/SKILL.md +43 -0
  57. package/dist/skills/ui-ux-design/SKILL.md +603 -0
  58. package/package.json +2 -2
@@ -0,0 +1,732 @@
1
+ """StringRay Hermes Plugin — full framework pipeline integration.
2
+
3
+ Mirrors the OpenCode strray-codex-injection.ts behavior:
4
+ 1. Captures ALL tool calls and logs to disk
5
+ 2. Runs quality gates on code-producing tools
6
+ 3. Runs pre/post processors via Node.js bridge
7
+ 4. Persists activity to activity.log + plugin-tool-events.log
8
+ 5. Tracks session statistics
9
+
10
+ Bridge protocol: JSON over stdin/stdout to bridge.mjs (Node.js).
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ try:
22
+ from . import schemas, tools
23
+ except ImportError:
24
+ # Standalone import (e.g., pytest discovery) — modules loaded separately
25
+ import importlib
26
+ import types
27
+ _pkg_dir = Path(__file__).resolve().parent
28
+ sys.path.insert(0, str(_pkg_dir))
29
+ schemas = importlib.import_module("schemas")
30
+ tools = importlib.import_module("tools")
31
+
32
+ logger = logging.getLogger("strray-hermes")
33
+
34
+ # ── Paths ─────────────────────────────────────────────────────
35
+
36
+ PLUGIN_DIR = Path(__file__).resolve().parent
37
+ BRIDGE_PATH = PLUGIN_DIR / "bridge.mjs"
38
+
39
+ # Project root: find the StringRay project directory
40
+ # The plugin lives at ~/.hermes/plugins/ which is NOT inside any project tree,
41
+ # so walking up from PLUGIN_DIR will never find the project. Instead:
42
+ # 1. Check STRRAY_PROJECT_ROOT env var (explicit override)
43
+ # 2. Walk up from cwd looking for node_modules/strray-ai (consumer install)
44
+ # 3. Walk up from cwd looking for .opencode/strray/features.json (dev repo)
45
+ # 4. Walk up from cwd looking for package.json (skip home dir)
46
+ def _find_project_root():
47
+ env_root = os.environ.get("STRRAY_PROJECT_ROOT") or os.environ.get("HERMES_PROJECT_ROOT")
48
+ if env_root:
49
+ p = Path(env_root).resolve()
50
+ if p.is_dir():
51
+ return p
52
+
53
+ cwd = Path.cwd()
54
+ home = Path.home()
55
+
56
+ # Walk up from cwd
57
+ d = cwd
58
+ for _ in range(20):
59
+ # node_modules/strray-ai — consumer install marker
60
+ if (d / "node_modules" / "strray-ai" / "package.json").exists():
61
+ return d
62
+ # .opencode/strray — dev repo marker
63
+ if (d / ".opencode" / "strray" / "features.json").exists():
64
+ return d
65
+ # package.json but not home dir
66
+ if d != home and (d / "package.json").exists():
67
+ return d
68
+ d = d.parent
69
+ if d == d.parent:
70
+ break
71
+
72
+ return cwd
73
+
74
+ PROJECT_ROOT = _find_project_root()
75
+ LOG_DIR = PROJECT_ROOT / "logs" / "framework"
76
+
77
+ # ── Constants ─────────────────────────────────────────────────
78
+
79
+ # Tools that produce/modify code — these get the full pipeline
80
+ _CODE_TOOLS = {"write_file", "patch", "execute_code", "write", "edit"}
81
+
82
+ # Map tool names to agent/skill for outcome tracking
83
+ _TOOL_AGENT_MAP = {
84
+ "write_file": ("code-reviewer", "write"),
85
+ "patch": ("code-reviewer", "patch"),
86
+ "execute_code": ("testing-lead", "execution"),
87
+ "write": ("code-reviewer", "write"),
88
+ "edit": ("code-reviewer", "edit"),
89
+ "terminal": ("testing-lead", "execution"),
90
+ "search_files": ("researcher", "search"),
91
+ "read_file": ("researcher", "read"),
92
+ "browser_*": ("researcher", "browser"),
93
+ "delegate_task": ("orchestrator", "delegation"),
94
+ }
95
+
96
+ # Tools where StringRay has a better alternative
97
+ # terminal: only nudge when the command looks lint/security/search related
98
+ _BETTER_WITH_STRRAY = {
99
+ "search_files": "Use mcp_strray_researcher_search_codebase for code pattern searches",
100
+ }
101
+
102
+ # Patterns that suggest the terminal command should use an MCP tool instead
103
+ _TERMINAL_NUDGE_PATTERNS = {
104
+ "grep": "Use mcp_strray_researcher_search_codebase instead of grep",
105
+ "rg ": "Use mcp_strray_researcher_search_codebase instead of ripgrep",
106
+ "eslint": "Use mcp_strray_lint_lint instead of raw eslint",
107
+ "npx eslint": "Use mcp_strray_lint_lint instead of raw eslint",
108
+ "npm audit": "Use mcp_strray_security_scan_security_scan instead of npm audit",
109
+ "yarn audit": "Use mcp_strray_security_scan_security_scan instead of yarn audit",
110
+ "find ": "Use search_files(target='files') instead of find",
111
+ "sed ": "Use patch tool instead of sed",
112
+ "awk ": "Use patch tool instead of awk",
113
+ }
114
+
115
+ # ── Session stats ─────────────────────────────────────────────
116
+
117
+ _INFERENCE_TUNE_INTERVAL = 100
118
+ _last_tune_tool_call_count = 0
119
+
120
+ _session_stats = {
121
+ "started_at": None,
122
+ "session_id": None,
123
+ "code_operations": 0,
124
+ "total_tool_calls": 0,
125
+ "strray_mcp_calls": 0,
126
+ "native_tool_calls": 0,
127
+ "quality_gate_runs": 0,
128
+ "quality_gate_blocks": 0,
129
+ "pre_processor_runs": 0,
130
+ "post_processor_runs": 0,
131
+ "bridge_calls": 0,
132
+ "bridge_errors": 0,
133
+ "subagent_dispatches": 0,
134
+ "subagent_validations": 0,
135
+ "subagent_blocks": 0,
136
+ }
137
+
138
+ # ── File logging ──────────────────────────────────────────────
139
+
140
+ def _ensure_log_dir():
141
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
142
+
143
+
144
+ def _log_to_file(filename, message):
145
+ """Append a timestamped line to a log file in logs/framework/."""
146
+ try:
147
+ _ensure_log_dir()
148
+ log_path = LOG_DIR / filename
149
+ timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
150
+ entry = f"{timestamp} {message}\n"
151
+ with open(log_path, "a", encoding="utf-8") as f:
152
+ f.write(entry)
153
+ except OSError:
154
+ pass # never break the agent over logging
155
+
156
+
157
+ def _log_tool_event(event_type, tool, args=None, duration=0, error=None):
158
+ """Log tool events in the same format as the OpenCode plugin."""
159
+ import random
160
+ job_id = f"plugin-{int(datetime.now(timezone.utc).timestamp() * 1000)}-{random.randint(100000, 999999)}"
161
+ if event_type == "start":
162
+ args_keys = list((args or {}).keys())
163
+ msg = f"[{job_id}] [agent] tool-started - INFO | {{\"tool\":\"{tool}\",\"args\":{json.dumps(args_keys)}}}"
164
+ else:
165
+ level = "ERROR" if error else "SUCCESS"
166
+ err_part = f",\"error\":\"{error}\"" if error else ""
167
+ msg = f"[{job_id}] [agent] tool-complete - {level} | {{\"tool\":\"{tool}\",\"duration\":{duration}{err_part}}}"
168
+ _log_to_file("plugin-tool-events.log", msg)
169
+
170
+
171
+ # ── Bridge calls ──────────────────────────────────────────────
172
+
173
+ def _call_bridge(command: dict, timeout: int = 10) -> dict:
174
+ """Call bridge.mjs with a JSON command, return parsed response."""
175
+ _session_stats["bridge_calls"] += 1
176
+ try:
177
+ result = subprocess.run(
178
+ ["node", str(BRIDGE_PATH), "--cwd", str(PROJECT_ROOT)],
179
+ input=json.dumps(command),
180
+ capture_output=True,
181
+ text=True,
182
+ timeout=timeout,
183
+ )
184
+ if result.returncode != 0:
185
+ _session_stats["bridge_errors"] += 1
186
+ return {"error": result.stderr[:300] if result.stderr else "bridge failed"}
187
+ return json.loads(result.stdout)
188
+ except FileNotFoundError:
189
+ _session_stats["bridge_errors"] += 1
190
+ return {"error": "node not found"}
191
+ except subprocess.TimeoutExpired:
192
+ _session_stats["bridge_errors"] += 1
193
+ return {"error": f"bridge timed out after {timeout}s"}
194
+ except (json.JSONDecodeError, OSError) as e:
195
+ _session_stats["bridge_errors"] += 1
196
+ return {"error": str(e)}
197
+
198
+
199
+ # ── Hook: pre_tool_call ───────────────────────────────────────
200
+
201
+ def _is_strray_mcp(tool_name: str) -> bool:
202
+ return tool_name.startswith("mcp_strray_")
203
+
204
+
205
+ def _on_pre_tool_call(tool_name: str, args: dict, task_id: str, **kwargs):
206
+ """Fires before ANY tool executes.
207
+
208
+ Pipeline:
209
+ 1. Track stats
210
+ 2. Log tool-start event to disk
211
+ 3. For code-producing tools: run quality gate + pre-processors via bridge
212
+ 4. For non-code tools: nudge if StringRay alternative exists
213
+ """
214
+ _session_stats["total_tool_calls"] += 1
215
+
216
+ # Log start event
217
+ _log_tool_event("start", tool_name, args)
218
+
219
+ # StringRay MCP tools — track but don't interfere
220
+ if _is_strray_mcp(tool_name):
221
+ _session_stats["strray_mcp_calls"] += 1
222
+ _log_to_file("activity.log", f"[quality-gate] SKIP (strray-mcp): {tool_name}")
223
+ return
224
+
225
+ # delegate_task: snapshot working tree so post_hook can validate changes
226
+ if tool_name == "delegate_task":
227
+ tid = kwargs.get("task_id", "") or args.get("task_id", "") or task_id
228
+ if tid:
229
+ _delegate_snapshots[tid] = _snapshot_working_tree()
230
+ _session_stats["subagent_dispatches"] += 1
231
+ _log_to_file("activity.log",
232
+ f"[pre-tool] SUBAGENT DISPATCH: task_id={tid}")
233
+ return
234
+
235
+ _session_stats["native_tool_calls"] += 1
236
+
237
+ # Code-producing tools get the full pipeline
238
+ if tool_name in _CODE_TOOLS:
239
+ _session_stats["code_operations"] += 1
240
+
241
+ # Extract file path for logging
242
+ file_path = None
243
+ if isinstance(args, dict):
244
+ file_path = args.get("path") or args.get("filePath")
245
+
246
+ _log_to_file("activity.log",
247
+ f"[pre-tool] CODE OPERATION: tool={tool_name} file={file_path}")
248
+
249
+ # Run quality gate via bridge
250
+ _session_stats["quality_gate_runs"] += 1
251
+ bridge_result = _call_bridge({
252
+ "command": "pre-process",
253
+ "tool": tool_name,
254
+ "args": args or {},
255
+ }, timeout=15)
256
+
257
+ if "error" not in bridge_result:
258
+ quality = bridge_result.get("qualityGate", {})
259
+ processors = bridge_result.get("processors", {})
260
+
261
+ # Log quality gate results
262
+ if quality.get("passed") is False:
263
+ violations = quality.get("violations", [])
264
+ _session_stats["quality_gate_blocks"] += 1
265
+ violation_msg = "; ".join(violations)
266
+ _log_to_file("activity.log",
267
+ f"[quality-gate] BLOCKED: tool={tool_name} violations={violation_msg}")
268
+ logger.warning(
269
+ "[strray] Quality gate BLOCKED %s: %s",
270
+ tool_name, violation_msg,
271
+ )
272
+ else:
273
+ _log_to_file("activity.log",
274
+ f"[quality-gate] PASSED: tool={tool_name}")
275
+
276
+ # Log processor results
277
+ if processors.get("ran"):
278
+ _session_stats["pre_processor_runs"] += 1
279
+ success = processors.get("success", True)
280
+ count = processors.get("processorCount", 0)
281
+ _log_to_file("activity.log",
282
+ f"[pre-processors] {'SUCCESS' if success else 'FAILED'}: "
283
+ f"{count} processors for {tool_name}")
284
+ if processors.get("details"):
285
+ for detail in processors["details"]:
286
+ status = "OK" if detail.get("success") else f"FAILED: {detail.get('error')}"
287
+ _log_to_file("activity.log",
288
+ f"[pre-processor] {detail.get('name', 'unknown')}: {status}")
289
+ else:
290
+ _log_to_file("activity.log",
291
+ f"[bridge] ERROR in pre-process: {bridge_result.get('error', 'unknown')}")
292
+ return
293
+
294
+ # Non-code tools: nudge for StringRay alternatives
295
+ if tool_name in _BETTER_WITH_STRRAY:
296
+ tip = _BETTER_WITH_STRRAY[tool_name]
297
+ logger.info("[strray] Tip: %s — %s", tool_name, tip)
298
+ _log_to_file("activity.log",
299
+ f"[nudge] {tool_name}: {tip}")
300
+
301
+ # Terminal: smart nudge based on command content
302
+ if tool_name == "terminal" and isinstance(args, dict):
303
+ cmd = args.get("command", "")
304
+ if isinstance(cmd, str):
305
+ for pattern, tip in _TERMINAL_NUDGE_PATTERNS.items():
306
+ if pattern in cmd:
307
+ logger.info("[strray] Tip: %s — %s", tool_name, tip)
308
+ _log_to_file("activity.log",
309
+ f"[nudge] {tool_name}: {tip}")
310
+ break
311
+
312
+
313
+ # ── Hook: post_tool_call ──────────────────────────────────────
314
+
315
+ def _on_post_tool_call(tool_name: str, args: dict, result, task_id: str, **kwargs):
316
+ """Fires after ANY tool returns.
317
+
318
+ Pipeline:
319
+ 1. Log tool-complete event to disk
320
+ 2. Extract file info from write/patch operations
321
+ 3. For code-producing tools: run post-processors via bridge
322
+ 4. Track file modifications for session context
323
+ """
324
+ duration = 0
325
+
326
+ # Extract file path — BUG FIX: only when path key exists with truthy value
327
+ file_path = None
328
+ if isinstance(args, dict) and tool_name in ("write_file", "patch"):
329
+ file_path = args.get("path")
330
+ if not file_path:
331
+ # No path key or empty string — skip file logging
332
+ pass
333
+
334
+ # Extract duration from result if available
335
+ if isinstance(result, dict):
336
+ duration = result.get("duration", 0)
337
+
338
+ # Log completion event
339
+ error = None
340
+ if isinstance(result, dict) and result.get("error"):
341
+ error = result["error"]
342
+ _log_tool_event("complete", tool_name, args, duration, error)
343
+
344
+ # Record outcome for the inference feedback loop
345
+ _record_tool_outcome(tool_name, args or {}, error is None)
346
+
347
+ # delegate_task: validate all files the subagent changed
348
+ if tool_name == "delegate_task":
349
+ tid = kwargs.get("task_id", "") or args.get("task_id", "") or task_id
350
+ if tid:
351
+ _validate_subagent_changes(tid)
352
+ return
353
+
354
+ # Track file modifications
355
+ if file_path:
356
+ _log_to_file("activity.log",
357
+ f"[post-tool] file-written: tool={tool_name} path={file_path}")
358
+
359
+ # Code-producing tools get post-processors
360
+ if tool_name in _CODE_TOOLS:
361
+ # Run post-processors via bridge
362
+ bridge_result = _call_bridge({
363
+ "command": "post-process",
364
+ "tool": tool_name,
365
+ "args": args or {},
366
+ "result": result,
367
+ "error": error,
368
+ }, timeout=15)
369
+
370
+ if "error" not in bridge_result:
371
+ processors = bridge_result.get("processors", {})
372
+ if processors.get("ran"):
373
+ _session_stats["post_processor_runs"] += 1
374
+ success = processors.get("success", True)
375
+ count = processors.get("processorCount", 0)
376
+ _log_to_file("activity.log",
377
+ f"[post-processors] {'SUCCESS' if success else 'FAILED'}: "
378
+ f"{count} processors for {tool_name}")
379
+ if processors.get("details"):
380
+ for detail in processors["details"]:
381
+ status = "OK" if detail.get("success") else f"FAILED: {detail.get('error')}"
382
+ _log_to_file("activity.log",
383
+ f"[post-processor] {detail.get('name', 'unknown')}: {status}")
384
+ else:
385
+ _log_to_file("activity.log",
386
+ f"[bridge] ERROR in post-process: {bridge_result.get('error', 'unknown')}")
387
+
388
+ # Auto inference tuning: every _INFERENCE_TUNE_INTERVAL tool calls,
389
+ # shell out to the inference tuner to close the feedback loop.
390
+ global _last_tune_tool_call_count
391
+ calls = _session_stats["total_tool_calls"]
392
+ if calls - _last_tune_tool_call_count >= _INFERENCE_TUNE_INTERVAL:
393
+ _last_tune_tool_call_count = calls
394
+ logger.info(
395
+ "[strray] Triggering inference tuning cycle (tool call #%d)", calls
396
+ )
397
+ _log_to_file("activity.log",
398
+ f"[inference-tune] auto-cycle at tool call #{calls}")
399
+ try:
400
+ _run_inference_tune()
401
+ except Exception as e:
402
+ logger.warning("[strray] Inference tuning failed: %s", e)
403
+
404
+
405
+ # ── Hook: session_start ───────────────────────────────────────
406
+
407
+ def _on_session_start(session_id: str, platform: str, **kwargs):
408
+ """Fires when a new session starts. Resets stats, logs to disk."""
409
+ _session_stats["started_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
410
+ _session_stats["session_id"] = session_id
411
+ for key in ("code_operations", "total_tool_calls", "strray_mcp_calls",
412
+ "native_tool_calls", "quality_gate_runs", "quality_gate_blocks",
413
+ "pre_processor_runs", "post_processor_runs",
414
+ "bridge_calls", "bridge_errors",
415
+ "subagent_dispatches", "subagent_validations", "subagent_blocks"):
416
+ _session_stats[key] = 0
417
+ global _last_tune_tool_call_count
418
+ _last_tune_tool_call_count = 0
419
+
420
+ _ensure_log_dir()
421
+ _log_to_file("activity.log",
422
+ f"[session-start] session={session_id} platform={platform}")
423
+ logger.info("[strray] Session %s started on %s", session_id, platform)
424
+
425
+
426
+ # ── Slash command ─────────────────────────────────────────────
427
+
428
+ def _strray_command(args: str) -> str:
429
+ """Slash command handler: /strray [status|stats|help]"""
430
+ cmd = (args or "status").strip().lower()
431
+
432
+ if cmd == "stats":
433
+ return (
434
+ f"StringRay Session Stats\n"
435
+ f" Session: {_session_stats['session_id'] or 'N/A'}\n"
436
+ f" Started: {_session_stats['started_at'] or 'N/A'}\n"
437
+ f" Tool calls: {_session_stats['total_tool_calls']}\n"
438
+ f" Code operations: {_session_stats['code_operations']}\n"
439
+ f" StringRay MCP: {_session_stats['strray_mcp_calls']}\n"
440
+ f" Native tools: {_session_stats['native_tool_calls']}\n"
441
+ f" Quality gate runs: {_session_stats['quality_gate_runs']}\n"
442
+ f" Quality gate blocks: {_session_stats['quality_gate_blocks']}\n"
443
+ f" Pre-processor runs: {_session_stats['pre_processor_runs']}\n"
444
+ f" Post-processor runs: {_session_stats['post_processor_runs']}\n"
445
+ f" Bridge calls: {_session_stats['bridge_calls']}\n"
446
+ f" Bridge errors: {_session_stats['bridge_errors']}\n"
447
+ f" Subagent dispatches: {_session_stats['subagent_dispatches']}\n"
448
+ f" Subagent validations: {_session_stats['subagent_validations']}\n"
449
+ f" Subagent blocks: {_session_stats['subagent_blocks']}"
450
+ )
451
+
452
+ if cmd == "help":
453
+ return (
454
+ "StringRay Commands:\n"
455
+ " /strray status — Plugin and framework health\n"
456
+ " /strray stats — Session pipeline statistics\n"
457
+ " /strray help — This message"
458
+ )
459
+
460
+ # Default: status (calls bridge health)
461
+ bridge_result = _call_bridge({"command": "health"}, timeout=10)
462
+ if "error" in bridge_result:
463
+ return f"StringRay plugin loaded. Bridge: {bridge_result['error']}"
464
+
465
+ return (
466
+ f"StringRay Hermes Plugin Status\n"
467
+ f" Framework: {bridge_result.get('framework', 'unknown')}\n"
468
+ f" Version: {bridge_result.get('version', 'unknown')}\n"
469
+ f" Quality Gate: {'ready' if bridge_result.get('components', {}).get('qualityGate') else 'not loaded'}\n"
470
+ f" Processors: {'ready' if bridge_result.get('components', {}).get('processorManager') else 'not loaded'}\n"
471
+ f" Project: {bridge_result.get('projectRoot', 'unknown')}\n"
472
+ f" Bridge calls: {_session_stats['bridge_calls']} (errors: {_session_stats['bridge_errors']})"
473
+ )
474
+
475
+
476
+ # ── Outcome tracking (feeds inference tuner) ──────────────────
477
+
478
+ _OUTCOMES_PATH = PROJECT_ROOT / "logs" / "framework" / "routing-outcomes.json"
479
+ _MAX_OUTCOMES = 1000
480
+
481
+
482
+ def _record_tool_outcome(tool_name: str, args: dict, success: bool):
483
+ """Append a routing outcome to routing-outcomes.json.
484
+
485
+ Writes directly to the JSON file (same format the TS outcome tracker uses)
486
+ so both OpenCode and Hermes plugin outcomes are visible to the tuner.
487
+ """
488
+ call_num = _session_stats.get("total_tool_calls", 0)
489
+
490
+ # Look up agent/skill mapping
491
+ agent, skill = "direct", tool_name
492
+ for pattern, mapped in _TOOL_AGENT_MAP.items():
493
+ if pattern.endswith("*"):
494
+ if tool_name.startswith(pattern[:-1]):
495
+ agent, skill = mapped
496
+ break
497
+ elif tool_name == pattern:
498
+ agent, skill = mapped
499
+ break
500
+
501
+ # Build description from args
502
+ if isinstance(args, dict):
503
+ content = args.get("content") or args.get("path") or args.get("filePath") or ""
504
+ description = str(content)[:200] if content else f"tool call: {tool_name}"
505
+ else:
506
+ description = f"tool call: {tool_name}"
507
+
508
+ outcome = {
509
+ "taskId": f"hermes-{call_num}",
510
+ "taskDescription": description,
511
+ "routedAgent": agent,
512
+ "routedSkill": skill,
513
+ "confidence": 0.8 if agent != "direct" else 0.5,
514
+ "success": success,
515
+ "timestamp": datetime.now(timezone.utc).isoformat(),
516
+ "routingMethod": "keyword" if agent != "direct" else "default",
517
+ }
518
+
519
+ try:
520
+ _OUTCOMES_PATH.parent.mkdir(parents=True, exist_ok=True)
521
+ if _OUTCOMES_PATH.exists():
522
+ with open(_OUTCOMES_PATH, "r") as f:
523
+ outcomes = json.load(f)
524
+ else:
525
+ outcomes = []
526
+
527
+ outcomes.append(outcome)
528
+ # Circular buffer — keep last N outcomes
529
+ if len(outcomes) > _MAX_OUTCOMES:
530
+ outcomes = outcomes[-_MAX_OUTCOMES:]
531
+
532
+ with open(_OUTCOMES_PATH, "w") as f:
533
+ json.dump(outcomes, f, indent=2)
534
+ except Exception as e:
535
+ logger.debug("[strray] outcome recording failed: %s", e)
536
+
537
+
538
+ # ── Inference tuning (auto-calibration) ────────────────────────
539
+
540
+ def _run_inference_tune():
541
+ """Shell out to strray-ai inference:tuner --run-once.
542
+
543
+ Runs in a background thread so it doesn't block the tool call pipeline.
544
+ The tuner reads routing outcomes, runs the analytics pipeline, and
545
+ writes back refined keyword mappings to routing-mappings.json.
546
+ """
547
+ import threading
548
+
549
+ def _tune():
550
+ try:
551
+ result = subprocess.run(
552
+ ["npx", "strray-ai", "inference:tuner", "--run-once"],
553
+ capture_output=True, text=True, timeout=30,
554
+ cwd=os.getcwd(),
555
+ )
556
+ if result.returncode == 0:
557
+ logger.info("[strray] Inference tuning cycle completed")
558
+ _log_to_file("activity.log",
559
+ "[inference-tune] cycle completed successfully")
560
+ else:
561
+ _log_to_file("activity.log",
562
+ f"[inference-tune] cycle failed (rc={result.returncode}): "
563
+ f"{result.stderr.strip()[:200]}")
564
+ except subprocess.TimeoutExpired:
565
+ _log_to_file("activity.log",
566
+ "[inference-tune] cycle timed out after 30s")
567
+ except Exception as e:
568
+ _log_to_file("activity.log",
569
+ f"[inference-tune] cycle error: {e}")
570
+
571
+ threading.Thread(target=_tune, daemon=True).start()
572
+
573
+
574
+ # ── Registration ──────────────────────────────────────────────
575
+
576
+ # ── Subagent (delegate_task) enforcement ────────────────────
577
+ # Subagents bypass all StringRay hooks because they run in isolated
578
+ # contexts. We enforce by snapshotting the working tree before dispatch
579
+ # and validating all changed files after return.
580
+
581
+ _delegate_snapshots: dict = {} # task_id → set of (path, mtime)
582
+
583
+
584
+ def _snapshot_working_tree() -> dict:
585
+ """Snapshot file mtimes under project root for subagent change detection."""
586
+ try:
587
+ result = subprocess.run(
588
+ ["git", "-C", str(PROJECT_ROOT), "diff", "--name-only", "HEAD"],
589
+ capture_output=True, text=True, timeout=5,
590
+ )
591
+ if result.returncode == 0:
592
+ changed = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
593
+ else:
594
+ changed = set()
595
+ except (FileNotFoundError, subprocess.TimeoutExpired):
596
+ changed = set()
597
+ return {"changed_before": changed}
598
+
599
+
600
+ def _validate_subagent_changes(task_id: str, **kwargs):
601
+ """After delegate_task returns, find what the subagent changed and validate."""
602
+ snapshot = _delegate_snapshots.pop(task_id, None)
603
+ if not snapshot:
604
+ return
605
+
606
+ before = snapshot.get("changed_before", set())
607
+
608
+ try:
609
+ result = subprocess.run(
610
+ ["git", "-C", str(PROJECT_ROOT), "diff", "--name-only", "HEAD"],
611
+ capture_output=True, text=True, timeout=5,
612
+ )
613
+ if result.returncode != 0:
614
+ return
615
+ after = set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
616
+ except (FileNotFoundError, subprocess.TimeoutExpired):
617
+ return
618
+
619
+ new_changes = after - before
620
+ if not new_changes:
621
+ return
622
+
623
+ # Filter to source files only (skip dist, node_modules, logs, etc.)
624
+ source_files = sorted(f for f in new_changes if any(
625
+ f.startswith(prefix) for prefix in ("src/", "dist/", "scripts/", ".opencode/plugins/")
626
+ ) and not any(
627
+ skip in f for skip in ("node_modules/", ".log", "__pycache__", ".map")
628
+ ))
629
+
630
+ if not source_files:
631
+ return
632
+
633
+ _session_stats["code_operations"] += len(source_files)
634
+ _session_stats["subagent_validations"] += 1
635
+
636
+ # Resolve to absolute paths for validation
637
+ abs_files = [str(PROJECT_ROOT / f) for f in source_files]
638
+
639
+ # Run validation on all changed files via bridge
640
+ bridge_result = _call_bridge({
641
+ "command": "validate",
642
+ "files": abs_files,
643
+ "operation": "modify",
644
+ }, timeout=30)
645
+
646
+ if "error" in bridge_result:
647
+ _log_to_file("activity.log",
648
+ f"[subagent-validate] BRIDGE ERROR: {bridge_result['error']}")
649
+ return
650
+
651
+ results = bridge_result.get("fileResults", bridge_result.get("results", {}))
652
+ for rel_path in source_files:
653
+ file_result = results.get(rel_path, results.get(str(PROJECT_ROOT / rel_path), {}))
654
+ passed = file_result.get("passed", True)
655
+ violations = file_result.get("violations", [])
656
+
657
+ if not passed:
658
+ _session_stats["quality_gate_blocks"] += 1
659
+ _session_stats["subagent_blocks"] += 1
660
+ _log_to_file("activity.log",
661
+ f"[subagent-validate] BLOCKED: {rel_path} "
662
+ f"violations={'; '.join(str(v) for v in violations[:3])}")
663
+ logger.warning(
664
+ "[strray] Subagent BLOCKED %s: %s",
665
+ rel_path, violations[:3],
666
+ )
667
+ else:
668
+ _log_to_file("activity.log",
669
+ f"[subagent-validate] PASSED: {rel_path}")
670
+
671
+
672
+ def register(ctx):
673
+ """Wire schemas to handlers and register lifecycle hooks."""
674
+ # ── Register tools ────────────────────────────────────────
675
+ ctx.register_tool(
676
+ name="strray_validate",
677
+ toolset="strray-hermes",
678
+ schema=schemas.STRRAY_VALIDATE,
679
+ handler=tools.strray_validate,
680
+ )
681
+ ctx.register_tool(
682
+ name="strray_codex_check",
683
+ toolset="strray-hermes",
684
+ schema=schemas.STRRAY_CODEX_CHECK,
685
+ handler=tools.strray_codex_check,
686
+ )
687
+ ctx.register_tool(
688
+ name="strray_health",
689
+ toolset="strray-hermes",
690
+ schema=schemas.STRRAY_HEALTH,
691
+ handler=tools.strray_health,
692
+ )
693
+ ctx.register_tool(
694
+ name="strray_hooks",
695
+ toolset="strray-hermes",
696
+ schema=schemas.STRRAY_HOOKS,
697
+ handler=tools.strray_hooks,
698
+ )
699
+
700
+ # ── Register hooks ────────────────────────────────────────
701
+ ctx.register_hook("pre_tool_call", _on_pre_tool_call)
702
+ ctx.register_hook("post_tool_call", _on_post_tool_call)
703
+
704
+ # Try to register session hooks
705
+ try:
706
+ ctx.register_hook("on_session_start", _on_session_start)
707
+ except (AttributeError, TypeError):
708
+ logger.debug("[strray] on_session_start hook not yet available")
709
+
710
+ # ── Register slash command ────────────────────────────────
711
+ try:
712
+ ctx.register_command(
713
+ name="strray",
714
+ handler=_strray_command,
715
+ description="StringRay status, stats, hooks, and enforcement info",
716
+ args_hint="[status|stats|help]",
717
+ aliases=("sr",),
718
+ )
719
+ except (AttributeError, TypeError):
720
+ logger.debug("[strray] Slash command registration not yet available")
721
+
722
+ # ── Bootstrap ─────────────────────────────────────────────
723
+ _ensure_log_dir()
724
+ _log_to_file("activity.log",
725
+ f"[plugin-loaded] StringRay Hermes Plugin v2.2 — "
726
+ f"4 tools, 2 hooks, subagent enforcement, bridge={BRIDGE_PATH.exists()}")
727
+
728
+ logger.info(
729
+ "[strray] Plugin v2.2 loaded: 4 tools, 2 hooks, "
730
+ "subagent enforcement active, bridge=%s",
731
+ BRIDGE_PATH.exists(),
732
+ )