strray-ai 1.15.6 → 1.15.7

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Enterprise AI Orchestration Framework for OpenCode/Claude Code**
4
4
 
5
- [![Version](https://img.shields.io/badge/version-1.15.6-blue?style=flat-square)](https://npmjs.com/package/strray-ai)
5
+ [![Version](https://img.shields.io/badge/version-1.15.7-blue?style=flat-square)](https://npmjs.com/package/strray-ai)
6
6
  [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
7
7
  [![Tests](https://img.shields.io/badge/tests-2311%20passed-brightgreen?style=flat-square)](src/__tests__)
8
8
  [![GitHub stars](https://img.shields.io/github/stars/htafolla/stringray?style=social)](https://github.com/htafolla/stringray)
package/package.json CHANGED
@@ -164,5 +164,5 @@
164
164
  "optionalDependencies": {
165
165
  "@rollup/rollup-linux-x64-gnu": "^4.30.1"
166
166
  },
167
- "version": "1.15.6"
167
+ "version": "1.15.7"
168
168
  }
@@ -413,6 +413,62 @@ def _strray_command(args: str) -> str:
413
413
 
414
414
  # ── Registration ──────────────────────────────────────────────
415
415
 
416
+ # Session tracking for new lifecycle hooks
417
+ _modified_files: list = []
418
+ _validation_results: list = []
419
+ _errors: list = []
420
+
421
+
422
+ def _on_file_write(file_path: str, content: str, tool_name: str, **kwargs):
423
+ """Fires when a code-producing tool writes a file.
424
+
425
+ Validates the file was written correctly and logs the event.
426
+ """
427
+ _log_to_file("activity.log",
428
+ f"[file-write] path={file_path} tool={tool_name} size={len(content) if content else 0}")
429
+
430
+ _modified_files.append({
431
+ "path": file_path,
432
+ "tool": tool_name,
433
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
434
+ })
435
+
436
+
437
+ def _on_validation_result(tool_name: str, passed: bool, violations: list, **kwargs):
438
+ """Fires when a validation/check completes.
439
+
440
+ Tracks validation outcomes for session context.
441
+ """
442
+ _log_to_file("activity.log",
443
+ f"[validation] tool={tool_name} passed={passed} violations={len(violations)}")
444
+
445
+ _validation_results.append({
446
+ "tool": tool_name,
447
+ "passed": passed,
448
+ "violation_count": len(violations),
449
+ "violations": violations[:5],
450
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
451
+ })
452
+
453
+
454
+ def _on_error(tool_name: str, error: str, args: dict, **kwargs):
455
+ """Fires when a tool call fails.
456
+
457
+ Logs the error and tracks it for session context.
458
+ """
459
+ _log_to_file("activity.log",
460
+ f"[error] tool={tool_name} error={str(error)[:200]}")
461
+
462
+ _session_stats["bridge_errors"] += 1
463
+
464
+ _errors.append({
465
+ "tool": tool_name,
466
+ "error": str(error)[:200],
467
+ "args_keys": list((args or {}).keys()),
468
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
469
+ })
470
+
471
+
416
472
  def register(ctx):
417
473
  """Wire schemas to handlers and register lifecycle hooks."""
418
474
  # ── Register tools ────────────────────────────────────────
@@ -434,6 +490,12 @@ def register(ctx):
434
490
  schema=schemas.STRRAY_HEALTH,
435
491
  handler=tools.strray_health,
436
492
  )
493
+ ctx.register_tool(
494
+ name="strray_hooks",
495
+ toolset="strray-hermes",
496
+ schema=schemas.STRRAY_HOOKS,
497
+ handler=tools.strray_hooks,
498
+ )
437
499
 
438
500
  # ── Register hooks ────────────────────────────────────────
439
501
  ctx.register_hook("pre_tool_call", _on_pre_tool_call)
@@ -445,12 +507,23 @@ def register(ctx):
445
507
  except (AttributeError, TypeError):
446
508
  logger.debug("[strray] on_session_start hook not yet available")
447
509
 
510
+ # Try to register new lifecycle hooks
511
+ for hook_name, hook_fn in [
512
+ ("on_file_write", _on_file_write),
513
+ ("on_validation_result", _on_validation_result),
514
+ ("on_error", _on_error),
515
+ ]:
516
+ try:
517
+ ctx.register_hook(hook_name, hook_fn)
518
+ except (AttributeError, TypeError):
519
+ logger.debug("[strray] %s hook not yet available", hook_name)
520
+
448
521
  # ── Register slash command ────────────────────────────────
449
522
  try:
450
523
  ctx.register_command(
451
524
  name="strray",
452
525
  handler=_strray_command,
453
- description="StringRay status, stats, and enforcement info",
526
+ description="StringRay status, stats, hooks, and enforcement info",
454
527
  args_hint="[status|stats|help]",
455
528
  aliases=("sr",),
456
529
  )
@@ -460,11 +533,11 @@ def register(ctx):
460
533
  # ── Bootstrap ─────────────────────────────────────────────
461
534
  _ensure_log_dir()
462
535
  _log_to_file("activity.log",
463
- f"[plugin-loaded] StringRay Hermes Plugin v2.0 — "
464
- f"3 tools, 2 hooks, bridge={BRIDGE_PATH.exists()}")
536
+ f"[plugin-loaded] StringRay Hermes Plugin v2.1 — "
537
+ f"4 tools, 5 hooks, bridge={BRIDGE_PATH.exists()}")
465
538
 
466
539
  logger.info(
467
- "[strray] Plugin v2.0 loaded: 3 tools, 2 hooks, "
540
+ "[strray] Plugin v2.1 loaded: 4 tools, 5 hooks, "
468
541
  "bridge=%s — full framework pipeline active",
469
542
  BRIDGE_PATH.exists(),
470
543
  )
@@ -9,8 +9,46 @@
9
9
  | `strray_validate` | Pre-commit validation with quality gates |
10
10
  | `strray_codex_check` | Code review against 60 Codex error-prevention rules |
11
11
  | `strray_health` | Framework health check |
12
+ | `strray_hooks` | Git hooks management (install, uninstall, list, status) |
12
13
  | `pre_tool_call` hook | Quality gates + nudges before every tool call |
13
14
  | `post_tool_call` hook | Post-processors + file tracking after every tool call |
15
+ | `on_file_write` hook | File write tracking and logging |
16
+ | `on_validation_result` hook | Validation outcome tracking |
17
+ | `on_error` hook | Error logging and session tracking |
18
+
19
+ ## Git Hooks
20
+
21
+ The plugin can install git hooks for automated validation:
22
+
23
+ | Hook | Type | Description |
24
+ |------|------|-------------|
25
+ | `pre-commit` | Blocking | TypeScript check + Codex validation before commit |
26
+ | `post-commit` | Non-blocking | Log archival + cleanup after commit |
27
+ | `pre-push` | Blocking | Full validation suite before push |
28
+ | `post-push` | Non-blocking | Comprehensive monitoring after push |
29
+
30
+ ### Install Hooks
31
+
32
+ ```
33
+ /strray hooks install
34
+ ```
35
+
36
+ Or use the tool directly:
37
+ ```
38
+ strray_hooks(action="install")
39
+ ```
40
+
41
+ ### Check Hook Status
42
+
43
+ ```
44
+ /strray hooks status
45
+ ```
46
+
47
+ ### Uninstall Hooks
48
+
49
+ ```
50
+ /strray hooks uninstall
51
+ ```
14
52
 
15
53
  ## Quick Test
16
54
 
@@ -18,6 +56,7 @@ After restarting Hermes, try:
18
56
 
19
57
  ```
20
58
  /strray status
59
+ /strray stats
21
60
  ```
22
61
 
23
62
  Or use the tools directly — `strray_health` will confirm the bridge is connected.
@@ -14,6 +14,7 @@
14
14
  * health - Quick framework health check
15
15
  * codex-check - Check code against codex rules
16
16
  * stats - Return bridge/framework statistics
17
+ * hooks - Manage git hooks (install, uninstall, list, status)
17
18
  *
18
19
  * Usage:
19
20
  * echo '{"command":"health"}' | node bridge.mjs [--cwd /path] # stdin mode
@@ -28,8 +29,12 @@ import {
28
29
  appendFileSync,
29
30
  mkdirSync,
30
31
  readdirSync,
32
+ lstatSync,
33
+ symlinkSync,
34
+ unlinkSync,
35
+ renameSync,
31
36
  } from "fs";
32
- import { join, dirname } from "path";
37
+ import { join, dirname, relative } from "path";
33
38
  import { fileURLToPath } from "url";
34
39
  import { homedir } from "os";
35
40
 
@@ -446,6 +451,136 @@ async function handleCodexCheck(input, projectRoot, logDir) {
446
451
  };
447
452
  }
448
453
 
454
+ function handleHooks(input, projectRoot) {
455
+ const { action, hooks } = input;
456
+ const hookTypes = hooks || ["pre-commit", "post-commit", "pre-push", "post-push"];
457
+ const gitHooksDir = join(projectRoot, ".git", "hooks");
458
+ const strrayHooksDir = join(projectRoot, "hooks");
459
+
460
+ if (!existsSync(gitHooksDir)) {
461
+ return { error: "Not a git repository — no .git/hooks directory" };
462
+ }
463
+
464
+ const result = { managed: [], missing: [], external: [], stale: [] };
465
+
466
+ // ── list / status ───────────────────────────────────────
467
+ if (action === "list" || action === "status") {
468
+ for (const hookName of hookTypes) {
469
+ const gitHook = join(gitHooksDir, hookName);
470
+ const strrayHook = join(strrayHooksDir, hookName);
471
+
472
+ if (!existsSync(gitHook)) {
473
+ result.missing.push(hookName);
474
+ } else {
475
+ try {
476
+ const content = readFileSync(gitHook, "utf-8");
477
+ if (content.includes("StringRay") || content.includes("strray") || content.includes("run-hook.js")) {
478
+ result.managed.push(hookName);
479
+ } else {
480
+ result.external.push(hookName);
481
+ }
482
+ } catch {
483
+ result.external.push(hookName);
484
+ }
485
+ }
486
+
487
+ // Check if strray source hook exists
488
+ if (!existsSync(strrayHook)) {
489
+ result.stale.push(hookName);
490
+ }
491
+ }
492
+
493
+ return {
494
+ status: "ok",
495
+ action,
496
+ hooks: result,
497
+ gitHooksDir,
498
+ strrayHooksDir,
499
+ };
500
+ }
501
+
502
+ // ── install ─────────────────────────────────────────────
503
+ if (action === "install") {
504
+ const installed = [];
505
+ const skipped = [];
506
+ const errors = [];
507
+
508
+ for (const hookName of hookTypes) {
509
+ const src = join(strrayHooksDir, hookName);
510
+ const dst = join(gitHooksDir, hookName);
511
+
512
+ if (!existsSync(src)) {
513
+ skipped.push(hookName);
514
+ continue;
515
+ }
516
+
517
+ try {
518
+ // Backup existing non-strray hooks
519
+ if (existsSync(dst)) {
520
+ const content = readFileSync(dst, "utf-8");
521
+ if (!content.includes("StringRay") && !content.includes("strray") && !content.includes("run-hook.js")) {
522
+ renameSync(dst, `${dst}.strray-backup`);
523
+ } else {
524
+ unlinkSync(dst);
525
+ }
526
+ }
527
+
528
+ // Create symlink
529
+ const rel = relative(join(gitHooksDir), src);
530
+ try {
531
+ // symlinkSync with 'junction' on Windows
532
+ symlinkSync(rel, dst);
533
+ } catch {
534
+ // Symlink may fail (permissions, cross-device) — copy instead
535
+ const srcContent = readFileSync(src, "utf-8");
536
+ writeFileSync(dst, srcContent, { mode: 0o755 });
537
+ }
538
+ installed.push(hookName);
539
+ } catch (err) {
540
+ errors.push({ hook: hookName, error: err.message });
541
+ }
542
+ }
543
+
544
+ return { status: "ok", action: "install", installed, skipped, errors };
545
+ }
546
+
547
+ // ── uninstall ───────────────────────────────────────────
548
+ if (action === "uninstall") {
549
+ const removed = [];
550
+ const restored = [];
551
+
552
+ for (const hookName of hookTypes) {
553
+ const dst = join(gitHooksDir, hookName);
554
+ const backup = `${dst}.strray-backup`;
555
+
556
+ if (!existsSync(dst)) continue;
557
+
558
+ try {
559
+ const content = readFileSync(dst, "utf-8");
560
+ const isStrray = content.includes("StringRay") || content.includes("strray") || content.includes("run-hook.js");
561
+
562
+ if (isStrray || lstatSync(dst).isSymbolicLink()) {
563
+ unlinkSync(dst);
564
+
565
+ // Restore backup if exists
566
+ if (existsSync(backup)) {
567
+ renameSync(backup, dst);
568
+ restored.push(hookName);
569
+ } else {
570
+ removed.push(hookName);
571
+ }
572
+ }
573
+ } catch {
574
+ // Skip unremovable hooks
575
+ }
576
+ }
577
+
578
+ return { status: "ok", action: "uninstall", removed, restored };
579
+ }
580
+
581
+ return { error: `Unknown hooks action: ${action}. Use: install, uninstall, list, status` };
582
+ }
583
+
449
584
  function handleStats() {
450
585
  return {
451
586
  frameworkReady,
@@ -458,7 +593,7 @@ function handleStats() {
458
593
 
459
594
  // ── Known commands for positional-arg mode ──────────────────
460
595
  const KNOWN_COMMANDS = new Set([
461
- "health", "stats", "pre-process", "post-process", "validate", "codex-check",
596
+ "health", "stats", "pre-process", "post-process", "validate", "codex-check", "hooks",
462
597
  ]);
463
598
 
464
599
  // ── Main ─────────────────────────────────────────────────────
@@ -540,6 +675,9 @@ async function main() {
540
675
  case "stats":
541
676
  response = handleStats();
542
677
  break;
678
+ case "hooks":
679
+ response = handleHooks(command, projectRoot);
680
+ break;
543
681
  default:
544
682
  response = { error: `Unknown command: ${cmd}` };
545
683
  }
@@ -1,11 +1,15 @@
1
1
  name: strray-hermes
2
- version: 1.0.0
3
- description: StringRay AI integration plugin — auto-enforcement hooks, quality gates, and tool awareness for Hermes Agent
2
+ version: 2.1.0
3
+ description: StringRay AI integration plugin — auto-enforcement hooks, quality gates, git hooks, and tool awareness for Hermes Agent
4
4
  author: StringRay AI
5
5
  provides_tools:
6
6
  - strray_validate
7
7
  - strray_codex_check
8
8
  - strray_health
9
+ - strray_hooks
9
10
  provides_hooks:
10
- - post_tool_call
11
11
  - pre_tool_call
12
+ - post_tool_call
13
+ - on_file_write
14
+ - on_validation_result
15
+ - on_error
@@ -69,3 +69,32 @@ STRRAY_HEALTH = {
69
69
  "properties": {},
70
70
  },
71
71
  }
72
+
73
+ STRRAY_HOOKS = {
74
+ "name": "strray_hooks",
75
+ "description": (
76
+ "Manage StringRay git hooks (install, uninstall, list, status). "
77
+ "Installs pre-commit, post-commit, pre-push, and post-push hooks "
78
+ "that run TypeScript validation, Codex checks, and monitoring. "
79
+ "Use 'install' to set up all hooks, 'list' to see current status."
80
+ ),
81
+ "parameters": {
82
+ "type": "object",
83
+ "properties": {
84
+ "action": {
85
+ "type": "string",
86
+ "enum": ["install", "uninstall", "list", "status"],
87
+ "description": "Action to perform on git hooks",
88
+ },
89
+ "hooks": {
90
+ "type": "array",
91
+ "items": {
92
+ "type": "string",
93
+ "enum": ["pre-commit", "post-commit", "pre-push", "post-push"],
94
+ },
95
+ "description": "Specific hooks to manage (default: all)",
96
+ },
97
+ },
98
+ "required": ["action"],
99
+ },
100
+ }
@@ -492,7 +492,7 @@ class TestRegisterIntegration(unittest.TestCase):
492
492
  ctx = MagicMock()
493
493
  pi.register(ctx)
494
494
  names = [c[1]["name"] for c in ctx.register_tool.call_args_list]
495
- self.assertEqual(set(names), {"strray_validate", "strray_codex_check", "strray_health"})
495
+ self.assertEqual(set(names), {"strray_validate", "strray_codex_check", "strray_health", "strray_hooks"})
496
496
 
497
497
  def test_toolset_name(self):
498
498
  ctx = MagicMock()
@@ -537,7 +537,8 @@ class TestRegisterIntegration(unittest.TestCase):
537
537
 
538
538
  def test_survives_missing_session_hook(self):
539
539
  ctx = MagicMock()
540
- ctx.register_hook.side_effect = [None, None, (AttributeError, None)]
540
+ # pre_tool_call, post_tool_call succeed; on_session_start fails; 3 lifecycle hooks fail
541
+ ctx.register_hook.side_effect = [None, None, AttributeError("nope"), AttributeError("nope"), AttributeError("nope"), AttributeError("nope")]
541
542
  pi.register(ctx) # should not raise
542
543
 
543
544
  def test_survives_missing_command_reg(self):
@@ -935,10 +936,256 @@ class TestBridgeHelperTimeoutDefault(unittest.TestCase):
935
936
  def test_custom_timeout(self):
936
937
  """_call_bridge respects custom timeout."""
937
938
  with patch("subprocess.run") as m:
938
- m.return_value = MagicMock(returncode=0, stdout='{"ok":true}', stderr="")
939
+ m.return_value = MagicMock(returncode=0, stdout='{\"ok\":true}', stderr="")
939
940
  tools_mod._call_bridge({"command": "health"}, timeout=5)
940
941
  self.assertEqual(m.call_args[1]["timeout"], 5)
941
942
 
942
943
 
944
+ class TestStrrayHooksTool(unittest.TestCase):
945
+ """Tests for the strray_hooks tool."""
946
+
947
+ def test_list_via_bridge(self):
948
+ """list action uses bridge when available."""
949
+ with patch.object(tools_mod, "_call_bridge", return_value={
950
+ "status": "ok", "action": "list",
951
+ "hooks": {"managed": ["pre-commit"], "missing": [], "external": [], "stale": []},
952
+ }) as m:
953
+ r = json.loads(tools_mod.strray_hooks({"action": "list"}))
954
+ self.assertEqual(r["status"], "ok")
955
+ self.assertEqual(r["via"], "bridge")
956
+ m.assert_called_once()
957
+ call_cmd = m.call_args[0][0]
958
+ self.assertEqual(call_cmd["command"], "hooks")
959
+ self.assertEqual(call_cmd["action"], "list")
960
+
961
+ def test_install_via_bridge(self):
962
+ """install action uses bridge."""
963
+ with patch.object(tools_mod, "_call_bridge", return_value={
964
+ "status": "ok", "action": "install", "installed": ["pre-commit", "post-commit"],
965
+ "skipped": [], "errors": [],
966
+ }) as m:
967
+ r = json.loads(tools_mod.strray_hooks({"action": "install"}))
968
+ self.assertEqual(r["via"], "bridge")
969
+ self.assertEqual(len(r["result"]["installed"]), 2)
970
+
971
+ def test_uninstall_via_bridge(self):
972
+ """uninstall action uses bridge."""
973
+ with patch.object(tools_mod, "_call_bridge", return_value={
974
+ "status": "ok", "action": "uninstall", "removed": ["pre-commit"], "restored": [],
975
+ }) as m:
976
+ r = json.loads(tools_mod.strray_hooks({"action": "uninstall"}))
977
+ self.assertEqual(r["via"], "bridge")
978
+
979
+ def test_bridge_error_fallback(self):
980
+ """Falls back to file-based when bridge errors."""
981
+ with patch.object(tools_mod, "_call_bridge", return_value={"error": "node not found"}):
982
+ # Without a real git repo, should return error
983
+ r = json.loads(tools_mod.strray_hooks({"action": "list"}))
984
+ self.assertIn("via", r)
985
+
986
+ def test_specific_hooks(self):
987
+ """Can request specific hooks."""
988
+ with patch.object(tools_mod, "_call_bridge", return_value={
989
+ "status": "ok", "action": "list",
990
+ "hooks": {"managed": [], "missing": ["pre-commit"], "external": [], "stale": []},
991
+ }) as m:
992
+ tools_mod.strray_hooks({"action": "list", "hooks": ["pre-commit"]})
993
+ call_cmd = m.call_args[0][0]
994
+ self.assertEqual(call_cmd["hooks"], ["pre-commit"])
995
+
996
+ def test_status_defaults_to_list(self):
997
+ """status action works like list."""
998
+ with patch.object(tools_mod, "_call_bridge", return_value={
999
+ "status": "ok", "action": "status",
1000
+ "hooks": {"managed": [], "missing": [], "external": [], "stale": []},
1001
+ }) as m:
1002
+ r = json.loads(tools_mod.strray_hooks({"action": "status"}))
1003
+ self.assertEqual(r["status"], "ok")
1004
+ m.assert_called_once()
1005
+
1006
+ def test_default_action_is_list(self):
1007
+ """Missing action defaults to list."""
1008
+ with patch.object(tools_mod, "_call_bridge", return_value={
1009
+ "status": "ok", "action": "list",
1010
+ "hooks": {"managed": [], "missing": [], "external": [], "stale": []},
1011
+ }) as m:
1012
+ tools_mod.strray_hooks({})
1013
+ call_cmd = m.call_args[0][0]
1014
+ self.assertEqual(call_cmd["action"], "list")
1015
+
1016
+
1017
+ class TestStrrayHooksSchema(unittest.TestCase):
1018
+ """Tests for the STRRAY_HOOKS schema."""
1019
+
1020
+ def test_schema_has_required_fields(self):
1021
+ s = schemas.STRRAY_HOOKS
1022
+ self.assertEqual(s["name"], "strray_hooks")
1023
+ self.assertIn("action", s["parameters"]["properties"])
1024
+ self.assertIn("hooks", s["parameters"]["properties"])
1025
+ self.assertIn("action", s["parameters"]["required"])
1026
+
1027
+ def test_action_enum(self):
1028
+ s = schemas.STRRAY_HOOKS
1029
+ action = s["parameters"]["properties"]["action"]
1030
+ self.assertIn("install", action["enum"])
1031
+ self.assertIn("uninstall", action["enum"])
1032
+ self.assertIn("list", action["enum"])
1033
+ self.assertIn("status", action["enum"])
1034
+
1035
+ def test_hooks_enum(self):
1036
+ s = schemas.STRRAY_HOOKS
1037
+ hooks = s["parameters"]["properties"]["hooks"]
1038
+ self.assertIn("pre-commit", hooks["items"]["enum"])
1039
+ self.assertIn("post-commit", hooks["items"]["enum"])
1040
+ self.assertIn("pre-push", hooks["items"]["enum"])
1041
+ self.assertIn("post-push", hooks["items"]["enum"])
1042
+
1043
+ def test_description_mentions_hooks(self):
1044
+ s = schemas.STRRAY_HOOKS
1045
+ self.assertIn("git hooks", s["description"])
1046
+
1047
+
1048
+ class TestLifecycleHooks(unittest.TestCase):
1049
+ """Tests for the new lifecycle hooks: on_file_write, on_validation_result, on_error."""
1050
+
1051
+ def setUp(self):
1052
+ # Reset tracking lists
1053
+ pi._modified_files = []
1054
+ pi._validation_results = []
1055
+ pi._errors = []
1056
+
1057
+ def test_on_file_write_logs(self):
1058
+ with tempfile.TemporaryDirectory() as td:
1059
+ log_dir = Path(td)
1060
+ original = pi.LOG_DIR
1061
+ pi.LOG_DIR = log_dir
1062
+
1063
+ pi._on_file_write("src/index.ts", "hello world", "write_file")
1064
+
1065
+ self.assertEqual(len(pi._modified_files), 1)
1066
+ self.assertEqual(pi._modified_files[0]["path"], "src/index.ts")
1067
+ self.assertEqual(pi._modified_files[0]["tool"], "write_file")
1068
+
1069
+ content = (log_dir / "activity.log").read_text()
1070
+ self.assertIn("[file-write]", content)
1071
+
1072
+ pi.LOG_DIR = original
1073
+
1074
+ def test_on_file_write_empty_content(self):
1075
+ pi._on_file_write("a.ts", "", "write_file")
1076
+ self.assertEqual(len(pi._modified_files), 1)
1077
+
1078
+ def test_on_validation_result_passed(self):
1079
+ with tempfile.TemporaryDirectory() as td:
1080
+ log_dir = Path(td)
1081
+ original = pi.LOG_DIR
1082
+ pi.LOG_DIR = log_dir
1083
+
1084
+ pi._on_validation_result("strray_validate", True, [])
1085
+
1086
+ self.assertEqual(len(pi._validation_results), 1)
1087
+ self.assertTrue(pi._validation_results[0]["passed"])
1088
+
1089
+ content = (log_dir / "activity.log").read_text()
1090
+ self.assertIn("[validation]", content)
1091
+
1092
+ pi.LOG_DIR = original
1093
+
1094
+ def test_on_validation_result_failed(self):
1095
+ pi._on_validation_result("strray_codex_check", False, ["console.log found"])
1096
+ self.assertFalse(pi._validation_results[0]["passed"])
1097
+ self.assertEqual(pi._validation_results[0]["violation_count"], 1)
1098
+ self.assertEqual(len(pi._validation_results[0]["violations"]), 1)
1099
+
1100
+ def test_on_validation_result_truncates_violations(self):
1101
+ many = [f"violation-{i}" for i in range(20)]
1102
+ pi._on_validation_result("test", False, many)
1103
+ # Should keep only first 5
1104
+ self.assertEqual(len(pi._validation_results[0]["violations"]), 5)
1105
+
1106
+ def test_on_error_logs(self):
1107
+ with tempfile.TemporaryDirectory() as td:
1108
+ log_dir = Path(td)
1109
+ original = pi.LOG_DIR
1110
+ pi.LOG_DIR = log_dir
1111
+
1112
+ pi._on_error("write_file", "disk full", {"path": "a.ts"})
1113
+
1114
+ self.assertEqual(len(pi._errors), 1)
1115
+ self.assertEqual(pi._errors[0]["tool"], "write_file")
1116
+
1117
+ content = (log_dir / "activity.log").read_text()
1118
+ self.assertIn("[error]", content)
1119
+
1120
+ pi.LOG_DIR = original
1121
+
1122
+ def test_on_error_increments_stats(self):
1123
+ initial = pi._session_stats["bridge_errors"]
1124
+ pi._on_error("terminal", "timeout", None)
1125
+ self.assertEqual(pi._session_stats["bridge_errors"], initial + 1)
1126
+
1127
+ def test_on_error_truncates_long_error(self):
1128
+ long_error = "x" * 500
1129
+ pi._on_error("tool", long_error, {})
1130
+ self.assertLessEqual(len(pi._errors[0]["error"]), 200)
1131
+
1132
+ def test_on_error_no_args(self):
1133
+ pi._on_error("tool", "crash", None)
1134
+ self.assertEqual(pi._errors[0]["args_keys"], [])
1135
+
1136
+
1137
+ class TestRegisterIntegrationV2_1(unittest.TestCase):
1138
+ """Test that register() wires all 4 tools and 5 hooks in v2.1."""
1139
+
1140
+ def test_wires_four_tools(self):
1141
+ ctx = MagicMock()
1142
+ pi.register(ctx)
1143
+ names = [c[1]["name"] for c in ctx.register_tool.call_args_list]
1144
+ self.assertEqual(set(names), {
1145
+ "strray_validate", "strray_codex_check",
1146
+ "strray_health", "strray_hooks",
1147
+ })
1148
+
1149
+ def test_strray_hooks_schema_wired(self):
1150
+ ctx = MagicMock()
1151
+ pi.register(ctx)
1152
+ sm = {c[1]["name"]: c[1]["schema"] for c in ctx.register_tool.call_args_list}
1153
+ self.assertIs(sm["strray_hooks"], schemas.STRRAY_HOOKS)
1154
+
1155
+ def test_strray_hooks_handler_wired(self):
1156
+ ctx = MagicMock()
1157
+ pi.register(ctx)
1158
+ hm = {c[1]["name"]: c[1]["handler"] for c in ctx.register_tool.call_args_list}
1159
+ self.assertIs(hm["strray_hooks"], tools_mod.strray_hooks)
1160
+
1161
+ def test_registers_five_hooks(self):
1162
+ ctx = MagicMock()
1163
+ pi.register(ctx)
1164
+ hook_names = [c[0][0] for c in ctx.register_hook.call_args_list]
1165
+ self.assertIn("pre_tool_call", hook_names)
1166
+ self.assertIn("post_tool_call", hook_names)
1167
+ self.assertIn("on_file_write", hook_names)
1168
+ self.assertIn("on_validation_result", hook_names)
1169
+ self.assertIn("on_error", hook_names)
1170
+
1171
+ def test_survives_missing_lifecycle_hooks(self):
1172
+ """New hooks should fail gracefully if not supported."""
1173
+ ctx = MagicMock()
1174
+ # All 5 hook registrations: pre_tool_call, post_tool_call, on_session_start, on_file_write, on_validation_result, on_error
1175
+ # Let the 3 new ones raise
1176
+ def side_effect(*args):
1177
+ raise AttributeError("not available")
1178
+ ctx.register_hook.side_effect = [None, None, None, side_effect, side_effect, side_effect]
1179
+ pi.register(ctx) # should not raise
1180
+
1181
+ def test_v2_1_log_message(self):
1182
+ ctx = MagicMock()
1183
+ with self.assertLogs("strray-hermes", level="INFO") as cm:
1184
+ pi.register(ctx)
1185
+ self.assertTrue(any("v2.1" in m for m in cm.output))
1186
+ self.assertTrue(any("4 tools" in m for m in cm.output))
1187
+ self.assertTrue(any("5 hooks" in m for m in cm.output))
1188
+
1189
+
943
1190
  if __name__ == "__main__":
944
1191
  unittest.main(verbosity=2)
@@ -5,6 +5,8 @@ Falls back to CLI (npx strray-ai) when bridge is unavailable.
5
5
  """
6
6
 
7
7
  import json
8
+ import os
9
+ import shutil
8
10
  import subprocess
9
11
  import sys
10
12
  from pathlib import Path
@@ -206,3 +208,112 @@ def strray_health(args: dict, **kwargs) -> str:
206
208
 
207
209
  # Fallback to CLI
208
210
  return _run_strray(["health"], timeout=15)
211
+
212
+
213
+ # ── Tool: strray_hooks ───────────────────────────────────────
214
+
215
+ def strray_hooks(args: dict, **kwargs) -> str:
216
+ """Manage StringRay git hooks.
217
+
218
+ Actions: install, uninstall, list, status
219
+ Uses bridge for hook management when available.
220
+ Falls back to direct file-based management when bridge unavailable.
221
+ """
222
+ action = args.get("action", "list")
223
+ hooks = args.get("hooks", ["pre-commit", "post-commit", "pre-push", "post-push"])
224
+
225
+ # Try bridge first
226
+ bridge_result = _call_bridge({
227
+ "command": "hooks",
228
+ "action": action,
229
+ "hooks": hooks,
230
+ }, timeout=15)
231
+
232
+ if "error" not in bridge_result:
233
+ return json.dumps({
234
+ "status": "ok",
235
+ "action": action,
236
+ "hooks": hooks,
237
+ "result": bridge_result,
238
+ "via": "bridge",
239
+ })
240
+
241
+ # Fallback: direct file-based hook management
242
+ git_hooks_dir = Path(PROJECT_ROOT) / ".git" / "hooks"
243
+ strray_hooks_dir = Path(PROJECT_ROOT) / "hooks"
244
+
245
+ if not git_hooks_dir.exists():
246
+ return json.dumps({"error": "Not a git repository", "via": "fallback"})
247
+
248
+ if action in ("list", "status"):
249
+ result = {"managed": [], "missing": [], "external": [], "stale": []}
250
+ for hook_name in hooks:
251
+ git_hook = git_hooks_dir / hook_name
252
+ strray_hook = strray_hooks_dir / hook_name
253
+ if not git_hook.exists():
254
+ result["missing"].append(hook_name)
255
+ else:
256
+ try:
257
+ content = git_hook.read_text()[:500]
258
+ if "StringRay" in content or "strray" in content or "run-hook.js" in content:
259
+ result["managed"].append(hook_name)
260
+ else:
261
+ result["external"].append(hook_name)
262
+ except OSError:
263
+ result["external"].append(hook_name)
264
+ if not strray_hook.exists():
265
+ result["stale"].append(hook_name)
266
+ return json.dumps({"status": "ok", "action": action, **result, "via": "fallback"})
267
+
268
+ if action == "install":
269
+ installed = []
270
+ skipped = []
271
+ for hook_name in hooks:
272
+ src = strray_hooks_dir / hook_name
273
+ dst = git_hooks_dir / hook_name
274
+ if not src.exists():
275
+ skipped.append(hook_name)
276
+ continue
277
+ try:
278
+ if dst.exists():
279
+ try:
280
+ content = dst.read_text()[:500]
281
+ if "StringRay" not in content and "strray" not in content:
282
+ dst.rename(dst.with_suffix(".strray-backup"))
283
+ else:
284
+ dst.unlink()
285
+ except OSError:
286
+ pass
287
+ try:
288
+ rel = os.path.relpath(str(src), str(git_hooks_dir))
289
+ os.symlink(rel, dst)
290
+ except OSError:
291
+ shutil.copy2(src, dst)
292
+ installed.append(hook_name)
293
+ except OSError:
294
+ pass
295
+ return json.dumps({"status": "ok", "action": "install", "installed": installed, "skipped": skipped, "via": "fallback"})
296
+
297
+ if action == "uninstall":
298
+ removed = []
299
+ restored = []
300
+ for hook_name in hooks:
301
+ dst = git_hooks_dir / hook_name
302
+ backup = dst.with_suffix(".strray-backup")
303
+ if not dst.exists():
304
+ continue
305
+ try:
306
+ content = dst.read_text()[:500]
307
+ is_strray = "StringRay" in content or "strray" in content or "run-hook.js" in content
308
+ if is_strray or dst.is_symlink():
309
+ dst.unlink()
310
+ if backup.exists():
311
+ shutil.move(str(backup), str(dst))
312
+ restored.append(hook_name)
313
+ else:
314
+ removed.append(hook_name)
315
+ except OSError:
316
+ pass
317
+ return json.dumps({"status": "ok", "action": "uninstall", "removed": removed, "restored": restored, "via": "fallback"})
318
+
319
+ return json.dumps({"error": f"Unknown action: {action}", "via": "fallback"})