@misterhuydo/sentinel 1.0.12 → 1.0.14
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/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +21 -21
- package/python/requirements.txt +2 -0
- package/python/sentinel/config_loader.py +13 -1
- package/python/sentinel/main.py +164 -1
- package/python/sentinel/reporter.py +29 -0
- package/python/sentinel/sentinel_boss.py +297 -0
- package/python/sentinel/slack_bot.py +228 -0
- package/templates/sentinel.properties +7 -0
- package/templates/workspace-sentinel.properties +8 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-21T17:
|
|
1
|
+
2026-03-21T17:34:17.867Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-21T17:
|
|
3
|
-
"checkpoint_at": "2026-03-21T17:
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-21T17:25:46.348Z",
|
|
3
|
+
"checkpoint_at": "2026-03-21T17:25:46.349Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@misterhuydo/sentinel",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Sentinel — Autonomous DevOps Agent installer and manager",
|
|
5
|
-
"bin": {
|
|
6
|
-
"sentinel": "./bin/sentinel.js"
|
|
7
|
-
},
|
|
8
|
-
"scripts": {
|
|
9
|
-
"prepublishOnly": "node scripts/bundle.js"
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"chalk": "^4.1.2",
|
|
13
|
-
"fs-extra": "^11.2.0",
|
|
14
|
-
"prompts": "^2.4.2"
|
|
15
|
-
},
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=16"
|
|
18
|
-
},
|
|
19
|
-
"author": "misterhuydo",
|
|
20
|
-
"license": "MIT"
|
|
21
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@misterhuydo/sentinel",
|
|
3
|
+
"version": "1.0.14",
|
|
4
|
+
"description": "Sentinel — Autonomous DevOps Agent installer and manager",
|
|
5
|
+
"bin": {
|
|
6
|
+
"sentinel": "./bin/sentinel.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prepublishOnly": "node scripts/bundle.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"chalk": "^4.1.2",
|
|
13
|
+
"fs-extra": "^11.2.0",
|
|
14
|
+
"prompts": "^2.4.2"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=16"
|
|
18
|
+
},
|
|
19
|
+
"author": "misterhuydo",
|
|
20
|
+
"license": "MIT"
|
|
21
|
+
}
|
package/python/requirements.txt
CHANGED
|
@@ -51,7 +51,13 @@ class SentinelConfig:
|
|
|
51
51
|
fix_confidence_threshold: float = 0.7
|
|
52
52
|
log_retention_hours: int = 48
|
|
53
53
|
anthropic_api_key: str = ""
|
|
54
|
-
marker_confirm_hours: int = 24
|
|
54
|
+
marker_confirm_hours: int = 24 # quiet period before confirming a fix
|
|
55
|
+
config_poll_interval: int = 60 # seconds between config repo git pulls
|
|
56
|
+
auto_upgrade: bool = True # auto-upgrade on new patch versions
|
|
57
|
+
version_pin: str = "" # if set, never upgrade beyond this version
|
|
58
|
+
upgrade_check_hours: int = 6 # hours between npm upgrade checks
|
|
59
|
+
slack_bot_token: str = "" # xoxb-...
|
|
60
|
+
slack_app_token: str = "" # xapp-... (Socket Mode)
|
|
55
61
|
|
|
56
62
|
|
|
57
63
|
@dataclass
|
|
@@ -137,6 +143,12 @@ class ConfigLoader:
|
|
|
137
143
|
c.log_retention_hours = int(d.get("LOG_RETENTION_HOURS", 48))
|
|
138
144
|
c.anthropic_api_key = d.get("ANTHROPIC_API_KEY", "")
|
|
139
145
|
c.marker_confirm_hours = int(d.get("MARKER_CONFIRM_HOURS", 24))
|
|
146
|
+
c.config_poll_interval = int(d.get("CONFIG_POLL_INTERVAL", 60))
|
|
147
|
+
c.auto_upgrade = d.get("AUTO_UPGRADE", "true").lower() != "false"
|
|
148
|
+
c.version_pin = d.get("VERSION_PIN", "")
|
|
149
|
+
c.upgrade_check_hours = int(d.get("UPGRADE_CHECK_HOURS", 6))
|
|
150
|
+
c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
|
|
151
|
+
c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
|
|
140
152
|
self.sentinel = c
|
|
141
153
|
|
|
142
154
|
def _load_log_sources(self):
|
package/python/sentinel/main.py
CHANGED
|
@@ -8,7 +8,10 @@ Usage:
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import asyncio
|
|
11
|
+
import json
|
|
11
12
|
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
12
15
|
import signal
|
|
13
16
|
import subprocess
|
|
14
17
|
import sys
|
|
@@ -24,7 +27,7 @@ from .log_fetcher import fetch_all
|
|
|
24
27
|
from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
|
|
25
28
|
from .issue_watcher import scan_issues, mark_done, IssueEvent
|
|
26
29
|
from .repo_router import route
|
|
27
|
-
from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification
|
|
30
|
+
from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification, send_upgrade_notification
|
|
28
31
|
from .state_store import StateStore
|
|
29
32
|
|
|
30
33
|
logging.basicConfig(
|
|
@@ -383,6 +386,160 @@ async def _send_startup_email_delayed(cfg, results: dict, delay: int = 300):
|
|
|
383
386
|
logger.error("Failed to send startup notification: %s", e)
|
|
384
387
|
|
|
385
388
|
|
|
389
|
+
# ── Config repo polling ──────────────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
def _poll_config_repo(cfg_loader: ConfigLoader) -> bool:
|
|
392
|
+
"""git pull the project directory if it is a git repo. Returns True if changes were pulled."""
|
|
393
|
+
project_dir = Path(".")
|
|
394
|
+
git_dir = project_dir / ".git"
|
|
395
|
+
if not git_dir.exists():
|
|
396
|
+
return False
|
|
397
|
+
try:
|
|
398
|
+
result = subprocess.run(
|
|
399
|
+
["git", "pull", "--rebase", "--autostash"],
|
|
400
|
+
cwd=str(project_dir),
|
|
401
|
+
capture_output=True, text=True, timeout=30,
|
|
402
|
+
)
|
|
403
|
+
if result.returncode != 0:
|
|
404
|
+
logger.warning("Config repo git pull failed: %s", result.stderr.strip())
|
|
405
|
+
return False
|
|
406
|
+
changed = "Already up to date." not in result.stdout
|
|
407
|
+
if changed:
|
|
408
|
+
logger.info("Config repo updated — reloading config\n%s", result.stdout.strip())
|
|
409
|
+
cfg_loader.load()
|
|
410
|
+
return changed
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.warning("Config repo poll error: %s", e)
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def _config_poll_loop(cfg_loader: ConfigLoader):
|
|
417
|
+
"""Background task: poll config repo for changes every config_poll_interval seconds."""
|
|
418
|
+
while True:
|
|
419
|
+
interval = cfg_loader.sentinel.config_poll_interval
|
|
420
|
+
await asyncio.sleep(interval)
|
|
421
|
+
try:
|
|
422
|
+
_poll_config_repo(cfg_loader)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
logger.warning("Config poll loop error: %s", e)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ── Auto-upgrade ─────────────────────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
def _find_sentinel_code_dir() -> Path | None:
|
|
430
|
+
"""Locate the sentinel/code dir relative to the project working directory."""
|
|
431
|
+
# Project dir is cwd; code dir is at ../code or ../../code depending on layout
|
|
432
|
+
for candidate in [
|
|
433
|
+
Path("../code"),
|
|
434
|
+
Path("../../code"),
|
|
435
|
+
Path.home() / "sentinel" / "code",
|
|
436
|
+
]:
|
|
437
|
+
if candidate.exists() and (candidate / "sentinel").exists():
|
|
438
|
+
return candidate.resolve()
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _parse_version(v: str) -> tuple:
|
|
443
|
+
try:
|
|
444
|
+
return tuple(int(x) for x in v.strip().lstrip("v").split(".")[:3])
|
|
445
|
+
except Exception:
|
|
446
|
+
return (0, 0, 0)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _check_and_upgrade(cfg: SentinelConfig) -> bool:
|
|
450
|
+
"""
|
|
451
|
+
Check npm registry for a newer patch/minor version of @misterhuydo/sentinel.
|
|
452
|
+
If found (and not blocked by VERSION_PIN), install it, copy Python source to
|
|
453
|
+
the code dir, then restart via os.execv.
|
|
454
|
+
Returns True if an upgrade was initiated (process will restart).
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
result = subprocess.run(
|
|
458
|
+
["npm", "show", "@misterhuydo/sentinel", "version"],
|
|
459
|
+
capture_output=True, text=True, timeout=30,
|
|
460
|
+
)
|
|
461
|
+
if result.returncode != 0:
|
|
462
|
+
logger.warning("npm show failed: %s", result.stderr.strip())
|
|
463
|
+
return False
|
|
464
|
+
latest = result.stdout.strip()
|
|
465
|
+
except Exception as e:
|
|
466
|
+
logger.warning("Upgrade check failed: %s", e)
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
# Determine current version from package installed globally
|
|
470
|
+
try:
|
|
471
|
+
cur_result = subprocess.run(
|
|
472
|
+
["npm", "list", "-g", "--depth=0", "--json"],
|
|
473
|
+
capture_output=True, text=True, timeout=30,
|
|
474
|
+
)
|
|
475
|
+
pkg_data = json.loads(cur_result.stdout or "{}")
|
|
476
|
+
deps = pkg_data.get("dependencies", {})
|
|
477
|
+
current = deps.get("@misterhuydo/sentinel", {}).get("version", "0.0.0")
|
|
478
|
+
except Exception:
|
|
479
|
+
current = "0.0.0"
|
|
480
|
+
|
|
481
|
+
current_t = _parse_version(current)
|
|
482
|
+
latest_t = _parse_version(latest)
|
|
483
|
+
|
|
484
|
+
if cfg.version_pin:
|
|
485
|
+
pin_t = _parse_version(cfg.version_pin)
|
|
486
|
+
if latest_t > pin_t:
|
|
487
|
+
logger.info("Upgrade available (%s → %s) but pinned to %s — skipping",
|
|
488
|
+
current, latest, cfg.version_pin)
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
if latest_t <= current_t:
|
|
492
|
+
logger.debug("No upgrade available (current=%s, latest=%s)", current, latest)
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
logger.info("Upgrading @misterhuydo/sentinel %s → %s", current, latest)
|
|
496
|
+
install = subprocess.run(
|
|
497
|
+
["npm", "install", "-g", f"@misterhuydo/sentinel@{latest}"],
|
|
498
|
+
capture_output=True, text=True, timeout=120,
|
|
499
|
+
)
|
|
500
|
+
if install.returncode != 0:
|
|
501
|
+
logger.error("npm install failed: %s", install.stderr.strip())
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
# Copy updated Python source from newly installed npm package to code dir
|
|
505
|
+
code_dir = _find_sentinel_code_dir()
|
|
506
|
+
if code_dir:
|
|
507
|
+
try:
|
|
508
|
+
npm_prefix_result = subprocess.run(
|
|
509
|
+
["npm", "root", "-g"], capture_output=True, text=True, timeout=10,
|
|
510
|
+
)
|
|
511
|
+
npm_root = npm_prefix_result.stdout.strip()
|
|
512
|
+
new_src = Path(npm_root) / "@misterhuydo" / "sentinel" / "sentinel"
|
|
513
|
+
if new_src.exists():
|
|
514
|
+
dst = code_dir / "sentinel"
|
|
515
|
+
shutil.copytree(str(new_src), str(dst), dirs_exist_ok=True)
|
|
516
|
+
logger.info("Python source updated at %s", dst)
|
|
517
|
+
except Exception as e:
|
|
518
|
+
logger.warning("Failed to copy updated Python source: %s", e)
|
|
519
|
+
|
|
520
|
+
logger.info("Upgrade complete — restarting Sentinel")
|
|
521
|
+
try:
|
|
522
|
+
from .reporter import send_upgrade_notification
|
|
523
|
+
send_upgrade_notification(cfg, current, latest)
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
os.execv(sys.executable, [sys.executable] + sys.argv)
|
|
528
|
+
return True # unreachable after execv
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def _upgrade_check_loop(cfg_loader: ConfigLoader):
|
|
532
|
+
"""Background task: check for upgrades every upgrade_check_hours hours."""
|
|
533
|
+
# Wait one full interval before first check so startup isn't slowed
|
|
534
|
+
await asyncio.sleep(cfg_loader.sentinel.upgrade_check_hours * 3600)
|
|
535
|
+
while True:
|
|
536
|
+
try:
|
|
537
|
+
_check_and_upgrade(cfg_loader.sentinel)
|
|
538
|
+
except Exception as e:
|
|
539
|
+
logger.warning("Upgrade check loop error: %s", e)
|
|
540
|
+
await asyncio.sleep(cfg_loader.sentinel.upgrade_check_hours * 3600)
|
|
541
|
+
|
|
542
|
+
|
|
386
543
|
# ── Entry point ──────────────────────────────────────────────────────────────────────────────────
|
|
387
544
|
|
|
388
545
|
async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
@@ -403,6 +560,12 @@ async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
|
403
560
|
logger.info("Startup checks passed — startup email in 5 minutes")
|
|
404
561
|
|
|
405
562
|
asyncio.ensure_future(_send_startup_email_delayed(cfg_loader.sentinel, results))
|
|
563
|
+
asyncio.ensure_future(_config_poll_loop(cfg_loader))
|
|
564
|
+
if cfg_loader.sentinel.auto_upgrade:
|
|
565
|
+
asyncio.ensure_future(_upgrade_check_loop(cfg_loader))
|
|
566
|
+
if cfg_loader.sentinel.slack_bot_token:
|
|
567
|
+
from .slack_bot import run_slack_bot
|
|
568
|
+
asyncio.ensure_future(run_slack_bot(cfg_loader, store))
|
|
406
569
|
|
|
407
570
|
while True:
|
|
408
571
|
try:
|
|
@@ -345,3 +345,32 @@ def send_startup_notification(cfg: SentinelConfig, results: dict):
|
|
|
345
345
|
subject = f'[Sentinel] {status_label} — {ts}'
|
|
346
346
|
_send_email(cfg, subject, html)
|
|
347
347
|
logger.info('Startup notification sent to %d recipient(s)', len(cfg.mails))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def send_upgrade_notification(cfg: SentinelConfig, old_version: str, new_version: str):
|
|
351
|
+
if not cfg.mails:
|
|
352
|
+
return
|
|
353
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
354
|
+
subject = f'[Sentinel] Upgraded {old_version} → {new_version}'
|
|
355
|
+
html = (
|
|
356
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
357
|
+
'<style>'
|
|
358
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
359
|
+
'h2{color:#1a73e8}'
|
|
360
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
361
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee}'
|
|
362
|
+
'.label{font-weight:bold;width:160px}'
|
|
363
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
364
|
+
'</style></head><body>'
|
|
365
|
+
'<h2>Sentinel auto-upgraded and restarted</h2>'
|
|
366
|
+
f'<p>{ts}</p>'
|
|
367
|
+
'<table>'
|
|
368
|
+
f'<tr><td class="label">Previous version</td><td class="mono">{old_version}</td></tr>'
|
|
369
|
+
f'<tr><td class="label">New version</td><td class="mono">{new_version}</td></tr>'
|
|
370
|
+
'</table>'
|
|
371
|
+
'<p>Sentinel has restarted automatically with the new version. No action required.</p>'
|
|
372
|
+
'<hr><small>Sentinel — Autonomous DevOps Agent</small>'
|
|
373
|
+
'</body></html>'
|
|
374
|
+
)
|
|
375
|
+
_send_email(cfg, subject, html)
|
|
376
|
+
logger.info('Upgrade notification sent: %s → %s', old_version, new_version)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sentinel_boss.py — Claude-backed Sentinel Boss.
|
|
3
|
+
|
|
4
|
+
Claude acts as the boss: reads project state, decides on actions,
|
|
5
|
+
executes them via tool use, and responds naturally. One agentic loop
|
|
6
|
+
per turn — Claude may call multiple tools before replying.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# ── System prompt ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
_SYSTEM = """\
|
|
22
|
+
You are Sentinel Boss — the AI interface for Sentinel, a 24/7 autonomous DevOps agent.
|
|
23
|
+
|
|
24
|
+
Sentinel watches production logs, detects errors, generates code fixes via Claude Code,
|
|
25
|
+
and opens GitHub PRs for admin review (or pushes directly if AUTO_PUBLISH=true).
|
|
26
|
+
|
|
27
|
+
Your job:
|
|
28
|
+
- Understand what the DevOps engineer needs in natural language
|
|
29
|
+
- Query Sentinel's live state (errors, fixes, open PRs) on their behalf
|
|
30
|
+
- Create issue reports when asked to investigate or fix something
|
|
31
|
+
- Control Sentinel (pause/resume) when asked
|
|
32
|
+
- Give honest, concise answers — you know this system inside out
|
|
33
|
+
|
|
34
|
+
Tone: direct, professional, like a senior engineer who owns the system.
|
|
35
|
+
Don't pad responses. Don't say "Great question!" or "Certainly!".
|
|
36
|
+
If you don't know something, use a tool to find out before saying you don't know.
|
|
37
|
+
|
|
38
|
+
When the engineer's request is fully handled, end your LAST message with the token: [DONE]
|
|
39
|
+
If you need a follow-up from them, do NOT include [DONE] — wait for their next message.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# ── Tool definitions ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
_TOOLS = [
|
|
45
|
+
{
|
|
46
|
+
"name": "get_status",
|
|
47
|
+
"description": (
|
|
48
|
+
"Get recent errors, fixes applied, fixes pending review, and open PRs. "
|
|
49
|
+
"Use for: 'what happened today?', 'any issues?', 'how are things?', "
|
|
50
|
+
"'what are the open PRs?', 'did sentinel fix anything?'"
|
|
51
|
+
),
|
|
52
|
+
"input_schema": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {
|
|
55
|
+
"hours": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"description": "Look-back window in hours (default 24)",
|
|
58
|
+
"default": 24,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "create_issue",
|
|
65
|
+
"description": (
|
|
66
|
+
"Queue a fix request for Sentinel to investigate on the next poll cycle. "
|
|
67
|
+
"Use whenever the engineer reports a bug, customer complaint, or asks you "
|
|
68
|
+
"to look into something specific. Include every detail they gave you."
|
|
69
|
+
),
|
|
70
|
+
"input_schema": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"description": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "Full problem description — everything the engineer told you",
|
|
76
|
+
},
|
|
77
|
+
"target_repo": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Repo name to assign to (omit to let Sentinel auto-route)",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"required": ["description"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "get_fix_details",
|
|
87
|
+
"description": "Get full details of a specific fix by fingerprint (8+ hex chars).",
|
|
88
|
+
"input_schema": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"fingerprint": {"type": "string"},
|
|
92
|
+
},
|
|
93
|
+
"required": ["fingerprint"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "list_pending_prs",
|
|
98
|
+
"description": "List all open Sentinel PRs awaiting admin review.",
|
|
99
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "pause_sentinel",
|
|
103
|
+
"description": (
|
|
104
|
+
"Pause ALL Sentinel fix activity immediately. "
|
|
105
|
+
"Use when the engineer says 'pause', 'stop', 'freeze', or 'hold off'."
|
|
106
|
+
),
|
|
107
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "resume_sentinel",
|
|
111
|
+
"description": "Resume Sentinel fix activity after a pause.",
|
|
112
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
|
|
120
|
+
if name == "get_status":
|
|
121
|
+
hours = int(inputs.get("hours", 24))
|
|
122
|
+
errors = store.get_recent_errors(hours)
|
|
123
|
+
fixes = store.get_recent_fixes(hours)
|
|
124
|
+
prs = store.get_open_prs()
|
|
125
|
+
top_errors = [
|
|
126
|
+
{
|
|
127
|
+
"message": e["message"][:120],
|
|
128
|
+
"count": e["count"],
|
|
129
|
+
"source": e["source"],
|
|
130
|
+
"last_seen": e["last_seen"],
|
|
131
|
+
}
|
|
132
|
+
for e in errors[:8]
|
|
133
|
+
]
|
|
134
|
+
return json.dumps({
|
|
135
|
+
"window_hours": hours,
|
|
136
|
+
"errors_detected": len(errors),
|
|
137
|
+
"top_errors": top_errors,
|
|
138
|
+
"fixes_applied": sum(1 for f in fixes if f["status"] == "applied"),
|
|
139
|
+
"fixes_pending": sum(1 for f in fixes if f["status"] == "pending"),
|
|
140
|
+
"fixes_failed": sum(1 for f in fixes if f["status"] == "failed"),
|
|
141
|
+
"open_prs": [
|
|
142
|
+
{
|
|
143
|
+
"repo": p["repo_name"],
|
|
144
|
+
"branch": p["branch"],
|
|
145
|
+
"pr_url": p["pr_url"],
|
|
146
|
+
"age": p.get("timestamp", ""),
|
|
147
|
+
}
|
|
148
|
+
for p in prs
|
|
149
|
+
],
|
|
150
|
+
"sentinel_paused": Path("SENTINEL_PAUSE").exists(),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if name == "create_issue":
|
|
154
|
+
description = inputs["description"]
|
|
155
|
+
target_repo = inputs.get("target_repo", "")
|
|
156
|
+
issues_dir = Path("issues")
|
|
157
|
+
issues_dir.mkdir(exist_ok=True)
|
|
158
|
+
fname = f"slack-{uuid.uuid4().hex[:8]}.txt"
|
|
159
|
+
content = (f"TARGET_REPO: {target_repo}\n\n" if target_repo else "") + description
|
|
160
|
+
(issues_dir / fname).write_text(content, encoding="utf-8")
|
|
161
|
+
logger.info("Boss created issue: %s", fname)
|
|
162
|
+
return json.dumps({
|
|
163
|
+
"status": "queued",
|
|
164
|
+
"file": fname,
|
|
165
|
+
"note": "Sentinel will pick this up on the next poll cycle.",
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if name == "get_fix_details":
|
|
169
|
+
fp = inputs["fingerprint"]
|
|
170
|
+
fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
|
|
171
|
+
if not fix:
|
|
172
|
+
# Fallback: search recent fixes by prefix
|
|
173
|
+
recent = store.get_recent_fixes(hours=72)
|
|
174
|
+
fix = next((f for f in recent if f.get("fingerprint", "").startswith(fp)), None)
|
|
175
|
+
return json.dumps(fix or {"error": "not found"})
|
|
176
|
+
|
|
177
|
+
if name == "list_pending_prs":
|
|
178
|
+
prs = store.get_open_prs()
|
|
179
|
+
return json.dumps({
|
|
180
|
+
"count": len(prs),
|
|
181
|
+
"open_prs": [
|
|
182
|
+
{
|
|
183
|
+
"repo": p["repo_name"],
|
|
184
|
+
"branch": p["branch"],
|
|
185
|
+
"pr_url": p["pr_url"],
|
|
186
|
+
"timestamp": p.get("timestamp", ""),
|
|
187
|
+
}
|
|
188
|
+
for p in prs
|
|
189
|
+
],
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if name == "pause_sentinel":
|
|
193
|
+
Path("SENTINEL_PAUSE").touch()
|
|
194
|
+
logger.info("Boss: SENTINEL_PAUSE created")
|
|
195
|
+
return json.dumps({"status": "paused"})
|
|
196
|
+
|
|
197
|
+
if name == "resume_sentinel":
|
|
198
|
+
p = Path("SENTINEL_PAUSE")
|
|
199
|
+
if p.exists():
|
|
200
|
+
p.unlink()
|
|
201
|
+
logger.info("Boss: SENTINEL_PAUSE removed")
|
|
202
|
+
return json.dumps({"status": "resumed"})
|
|
203
|
+
|
|
204
|
+
return json.dumps({"error": f"unknown tool: {name}"})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Main entry point ──────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async def handle_message(
|
|
210
|
+
message: str,
|
|
211
|
+
history: list,
|
|
212
|
+
cfg_loader,
|
|
213
|
+
store,
|
|
214
|
+
) -> tuple[str, bool]:
|
|
215
|
+
"""
|
|
216
|
+
Process one user message through the Sentinel Boss (Claude with tool use).
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
message: The user's Slack message text.
|
|
220
|
+
history: Conversation history list — mutated in place (role/content dicts).
|
|
221
|
+
cfg_loader: ConfigLoader for repo/sentinel config.
|
|
222
|
+
store: StateStore for DB queries.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
(reply_text, is_done)
|
|
226
|
+
is_done=True → session complete, release the Slack queue slot.
|
|
227
|
+
is_done=False → waiting for user follow-up, keep the slot.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
import anthropic
|
|
231
|
+
except ImportError:
|
|
232
|
+
return (
|
|
233
|
+
":warning: `anthropic` package not installed. Run: `pip install anthropic`",
|
|
234
|
+
True,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
238
|
+
if not api_key:
|
|
239
|
+
return (
|
|
240
|
+
":warning: `ANTHROPIC_API_KEY` not configured in `sentinel.properties`.",
|
|
241
|
+
True,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
245
|
+
|
|
246
|
+
# Build system context snapshot
|
|
247
|
+
paused = Path("SENTINEL_PAUSE").exists()
|
|
248
|
+
repos = list(cfg_loader.repos.keys())
|
|
249
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
250
|
+
system = (
|
|
251
|
+
_SYSTEM
|
|
252
|
+
+ f"\n\nCurrent time: {ts}"
|
|
253
|
+
+ f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
|
|
254
|
+
+ f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
history.append({"role": "user", "content": message})
|
|
258
|
+
messages = list(history)
|
|
259
|
+
|
|
260
|
+
# Agentic loop — Claude may call multiple tools before giving a final reply
|
|
261
|
+
while True:
|
|
262
|
+
response = client.messages.create(
|
|
263
|
+
model="claude-opus-4-6",
|
|
264
|
+
max_tokens=1024,
|
|
265
|
+
system=system,
|
|
266
|
+
tools=_TOOLS,
|
|
267
|
+
messages=messages,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
text_parts = []
|
|
271
|
+
tool_blocks = []
|
|
272
|
+
for block in response.content:
|
|
273
|
+
if block.type == "text":
|
|
274
|
+
text_parts.append(block.text)
|
|
275
|
+
elif block.type == "tool_use":
|
|
276
|
+
tool_blocks.append(block)
|
|
277
|
+
|
|
278
|
+
if not tool_blocks:
|
|
279
|
+
# Final response — no more tool calls
|
|
280
|
+
reply = " ".join(text_parts).strip()
|
|
281
|
+
is_done = "[DONE]" in reply
|
|
282
|
+
reply = reply.replace("[DONE]", "").strip()
|
|
283
|
+
history.append({"role": "assistant", "content": response.content})
|
|
284
|
+
return reply, is_done
|
|
285
|
+
|
|
286
|
+
# Execute tools and continue
|
|
287
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
288
|
+
tool_results = []
|
|
289
|
+
for tc in tool_blocks:
|
|
290
|
+
result = _run_tool(tc.name, tc.input, cfg_loader, store)
|
|
291
|
+
logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
|
|
292
|
+
tool_results.append({
|
|
293
|
+
"type": "tool_result",
|
|
294
|
+
"tool_use_id": tc.id,
|
|
295
|
+
"content": result,
|
|
296
|
+
})
|
|
297
|
+
messages.append({"role": "user", "content": tool_results})
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
slack_bot.py — Slack integration for Sentinel Boss.
|
|
3
|
+
|
|
4
|
+
One DevOps member is handled at a time per Sentinel instance.
|
|
5
|
+
Others are queued and notified of their position. When the current
|
|
6
|
+
session finishes, the next person is picked up automatically.
|
|
7
|
+
|
|
8
|
+
Setup (api.slack.com):
|
|
9
|
+
1. Create a Slack App → Enable Socket Mode → copy App-Level Token (xapp-...)
|
|
10
|
+
2. Add Bot Token Scopes: app_mentions:read, chat:write, im:history,
|
|
11
|
+
channels:history, users:read
|
|
12
|
+
3. Enable Events: app_mention, message.im, message.channels
|
|
13
|
+
4. Install to workspace → copy Bot Token (xoxb-...)
|
|
14
|
+
5. Add to sentinel.properties:
|
|
15
|
+
SLACK_BOT_TOKEN=xoxb-...
|
|
16
|
+
SLACK_APP_TOKEN=xapp-...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .sentinel_boss import handle_message
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Session and Queue ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class _Session:
|
|
33
|
+
user_id: str
|
|
34
|
+
user_name: str
|
|
35
|
+
channel: str
|
|
36
|
+
history: list = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Queue:
|
|
40
|
+
"""
|
|
41
|
+
Global sequential queue for a Sentinel instance.
|
|
42
|
+
One member at a time; others wait.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
47
|
+
self._active: Optional[_Session] = None
|
|
48
|
+
self._waiting: list[tuple[_Session, str]] = [] # (session, first_message)
|
|
49
|
+
|
|
50
|
+
async def try_activate(
|
|
51
|
+
self, user_id: str, user_name: str, channel: str
|
|
52
|
+
) -> tuple[str, int, Optional[_Session]]:
|
|
53
|
+
"""
|
|
54
|
+
Returns:
|
|
55
|
+
('active', 0, session) — slot is free, session started
|
|
56
|
+
('continuing', 0, session) — same user already has the slot
|
|
57
|
+
('queued', pos, None) — added to wait queue at position pos
|
|
58
|
+
"""
|
|
59
|
+
async with self._lock:
|
|
60
|
+
if self._active is None:
|
|
61
|
+
self._active = _Session(user_id, user_name, channel)
|
|
62
|
+
return "active", 0, self._active
|
|
63
|
+
|
|
64
|
+
if self._active.user_id == user_id:
|
|
65
|
+
return "continuing", 0, self._active
|
|
66
|
+
|
|
67
|
+
# Deduplicate — don't queue the same user twice
|
|
68
|
+
if not any(s.user_id == user_id for s, _ in self._waiting):
|
|
69
|
+
self._waiting.append((_Session(user_id, user_name, channel), ""))
|
|
70
|
+
|
|
71
|
+
pos = next(
|
|
72
|
+
i + 1
|
|
73
|
+
for i, (s, _) in enumerate(self._waiting)
|
|
74
|
+
if s.user_id == user_id
|
|
75
|
+
)
|
|
76
|
+
return "queued", pos, None
|
|
77
|
+
|
|
78
|
+
async def update_waiting_message(self, user_id: str, message: str):
|
|
79
|
+
"""Overwrite the first message for a queued user (they may rephrase while waiting)."""
|
|
80
|
+
async with self._lock:
|
|
81
|
+
for i, (s, _) in enumerate(self._waiting):
|
|
82
|
+
if s.user_id == user_id:
|
|
83
|
+
self._waiting[i] = (s, message)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
async def complete(self) -> Optional[tuple[_Session, str]]:
|
|
87
|
+
"""Release the active slot. Returns (next_session, first_message) or None."""
|
|
88
|
+
async with self._lock:
|
|
89
|
+
prev = self._active
|
|
90
|
+
self._active = None
|
|
91
|
+
if self._waiting:
|
|
92
|
+
session, msg = self._waiting.pop(0)
|
|
93
|
+
self._active = session
|
|
94
|
+
return session, msg
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def active_user_name(self) -> Optional[str]:
|
|
98
|
+
return self._active.user_name if self._active else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_queue = _Queue()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Slack bot ─────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async def run_slack_bot(cfg_loader, store) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Start Slack Bolt in Socket Mode as an asyncio background task.
|
|
109
|
+
Exits silently if tokens are missing or slack-bolt is not installed.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
from slack_bolt.async_app import AsyncApp
|
|
113
|
+
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
|
114
|
+
except ImportError:
|
|
115
|
+
logger.warning(
|
|
116
|
+
"slack-bolt not installed — Slack bot disabled. "
|
|
117
|
+
"Run: pip install slack-bolt"
|
|
118
|
+
)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
cfg = cfg_loader.sentinel
|
|
122
|
+
if not cfg.slack_bot_token or not cfg.slack_app_token:
|
|
123
|
+
logger.info("SLACK_BOT_TOKEN / SLACK_APP_TOKEN not set — Slack bot disabled")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
app = AsyncApp(token=cfg.slack_bot_token)
|
|
127
|
+
|
|
128
|
+
# ── Event handlers ────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
@app.event("app_mention")
|
|
131
|
+
async def on_mention(event, client):
|
|
132
|
+
await _dispatch(event, client, cfg_loader, store)
|
|
133
|
+
|
|
134
|
+
@app.event("message")
|
|
135
|
+
async def on_message(event, client):
|
|
136
|
+
# Handle DMs only (channel_type == "im"); ignore bot messages
|
|
137
|
+
if event.get("channel_type") == "im" and not event.get("bot_id"):
|
|
138
|
+
await _dispatch(event, client, cfg_loader, store)
|
|
139
|
+
|
|
140
|
+
# ── Start ─────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
|
|
143
|
+
logger.info("Sentinel Boss connected to Slack (Socket Mode)")
|
|
144
|
+
await handler.start_async()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
150
|
+
user_id = event.get("user", "")
|
|
151
|
+
channel = event.get("channel", "")
|
|
152
|
+
text = _strip_mention(event.get("text", "")).strip()
|
|
153
|
+
|
|
154
|
+
if not text:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
user_name = await _resolve_name(client, user_id)
|
|
158
|
+
|
|
159
|
+
status, pos, session = await _queue.try_activate(user_id, user_name, channel)
|
|
160
|
+
|
|
161
|
+
if status == "queued":
|
|
162
|
+
await _queue.update_waiting_message(user_id, text)
|
|
163
|
+
active = _queue.active_user_name() or "someone"
|
|
164
|
+
await _post(client, channel,
|
|
165
|
+
f"I'm working with *{active}* right now. "
|
|
166
|
+
f"You're *#{pos}* in the queue — I'll come to you when done. :hourglass_flowing_sand:"
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Active or continuing — process the turn
|
|
171
|
+
await _run_turn(session, text, client, cfg_loader, store)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Turn processor ────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
async def _run_turn(session: _Session, message: str, client, cfg_loader, store) -> None:
|
|
177
|
+
channel = session.channel
|
|
178
|
+
|
|
179
|
+
# Typing indicator
|
|
180
|
+
await _post(client, channel, "_thinking..._")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
reply, is_done = await handle_message(
|
|
184
|
+
message, session.history, cfg_loader, store
|
|
185
|
+
)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.exception("Sentinel Boss error: %s", e)
|
|
188
|
+
await _post(client, channel, f":warning: Unhandled error: {e}")
|
|
189
|
+
is_done = True
|
|
190
|
+
|
|
191
|
+
await _post(client, channel, reply)
|
|
192
|
+
|
|
193
|
+
if is_done:
|
|
194
|
+
next_item = await _queue.complete()
|
|
195
|
+
if next_item:
|
|
196
|
+
next_session, first_msg = next_item
|
|
197
|
+
await _post(
|
|
198
|
+
client, channel,
|
|
199
|
+
f":white_check_mark: Done with *{session.user_name}*. "
|
|
200
|
+
f"<@{next_session.user_id}> — you're up!"
|
|
201
|
+
)
|
|
202
|
+
if first_msg:
|
|
203
|
+
# Process their first message immediately
|
|
204
|
+
await _run_turn(next_session, first_msg, client, cfg_loader, store)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
async def _post(client, channel: str, text: str) -> None:
|
|
210
|
+
try:
|
|
211
|
+
await client.chat_postMessage(channel=channel, text=text)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.warning("Slack post failed: %s", e)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _resolve_name(client, user_id: str) -> str:
|
|
217
|
+
try:
|
|
218
|
+
info = await client.users_info(user=user_id)
|
|
219
|
+
profile = info["user"]["profile"]
|
|
220
|
+
return profile.get("display_name") or profile.get("real_name") or user_id
|
|
221
|
+
except Exception:
|
|
222
|
+
return user_id
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _strip_mention(text: str) -> str:
|
|
226
|
+
"""Remove leading <@BOTID> mention from message text."""
|
|
227
|
+
import re
|
|
228
|
+
return re.sub(r"^<@[A-Z0-9]+>\s*", "", text)
|
|
@@ -18,3 +18,10 @@ WORKSPACE_DIR=./workspace
|
|
|
18
18
|
|
|
19
19
|
# Claude Code auth — set if using API key, leave blank for OAuth
|
|
20
20
|
# ANTHROPIC_API_KEY=sk-ant-...
|
|
21
|
+
|
|
22
|
+
# Slack Bot (optional) — Sentinel Boss conversational interface
|
|
23
|
+
# Create a Slack App at api.slack.com, enable Socket Mode, add scopes:
|
|
24
|
+
# app_mentions:read, chat:write, im:history, channels:history, users:read
|
|
25
|
+
# Then install to workspace and paste both tokens here.
|
|
26
|
+
# SLACK_BOT_TOKEN=xoxb-...
|
|
27
|
+
# SLACK_APP_TOKEN=xapp-...
|
|
@@ -18,3 +18,11 @@ LOG_RETENTION_HOURS=48
|
|
|
18
18
|
|
|
19
19
|
# Claude Code binary path
|
|
20
20
|
CLAUDE_CODE_BIN=claude
|
|
21
|
+
|
|
22
|
+
# Auto-upgrade: check npm for a newer @misterhuydo/sentinel every N hours and restart
|
|
23
|
+
AUTO_UPGRADE=true
|
|
24
|
+
UPGRADE_CHECK_HOURS=6
|
|
25
|
+
# VERSION_PIN=1.0.12 # uncomment to prevent upgrades past this version
|
|
26
|
+
|
|
27
|
+
# Config repo polling: if the project dir is a git repo, pull for config changes every N seconds
|
|
28
|
+
CONFIG_POLL_INTERVAL=60
|