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 +1 -1
- package/package.json +1 -1
- package/src/integrations/hermes-agent/__init__.py +77 -4
- package/src/integrations/hermes-agent/after-install.md +39 -0
- package/src/integrations/hermes-agent/bridge.mjs +140 -2
- package/src/integrations/hermes-agent/plugin.yaml +7 -3
- package/src/integrations/hermes-agent/schemas.py +29 -0
- package/src/integrations/hermes-agent/test_plugin.py +250 -3
- package/src/integrations/hermes-agent/tools.py +111 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Enterprise AI Orchestration Framework for OpenCode/Claude Code**
|
|
4
4
|
|
|
5
|
-
[](https://npmjs.com/package/strray-ai)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](src/__tests__)
|
|
8
8
|
[](https://github.com/htafolla/stringray)
|
package/package.json
CHANGED
|
@@ -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.
|
|
464
|
-
f"
|
|
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.
|
|
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
|
|
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
|
-
|
|
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"})
|