@misterhuydo/sentinel 1.0.50 → 1.0.52
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 +1 -1
- package/python/sentinel/config_loader.py +4 -0
- package/python/sentinel/fix_engine.py +1 -1
- package/python/sentinel/sentinel_boss.py +1 -1
- package/python/sentinel/slack_bot.py +115 -1
- package/python/sentinel/state_store.py +26 -0
- package/templates/sentinel.properties +11 -1
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-22T10:41:20.633Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-22T10:
|
|
3
|
-
"checkpoint_at": "2026-03-22T10:
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-22T10:44:11.780Z",
|
|
3
|
+
"checkpoint_at": "2026-03-22T10:44:11.781Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -60,6 +60,8 @@ class SentinelConfig:
|
|
|
60
60
|
slack_bot_token: str = "" # xoxb-...
|
|
61
61
|
slack_app_token: str = "" # xapp-... (Socket Mode)
|
|
62
62
|
slack_channel: str = "" # optional: restrict to one channel ID or name
|
|
63
|
+
slack_watch_channels: list[str] = field(default_factory=list) # channels to passively monitor for bot errors
|
|
64
|
+
slack_watch_bot_ids: list[str] = field(default_factory=list) # optional: only watch these bot IDs
|
|
63
65
|
project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
|
|
64
66
|
|
|
65
67
|
|
|
@@ -153,6 +155,8 @@ class ConfigLoader:
|
|
|
153
155
|
c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
|
|
154
156
|
c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
|
|
155
157
|
c.slack_channel = d.get("SLACK_CHANNEL", "")
|
|
158
|
+
c.slack_watch_channels = _csv(d.get("SLACK_WATCH_CHANNELS", ""))
|
|
159
|
+
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
156
160
|
c.project_name = d.get("PROJECT_NAME", "")
|
|
157
161
|
self.sentinel = c
|
|
158
162
|
|
|
@@ -124,7 +124,7 @@ def generate_fix(
|
|
|
124
124
|
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
|
|
125
125
|
try:
|
|
126
126
|
result = subprocess.run(
|
|
127
|
-
[cfg.claude_code_bin, "--print", prompt],
|
|
127
|
+
[cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt],
|
|
128
128
|
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
|
|
129
129
|
)
|
|
130
130
|
except subprocess.TimeoutExpired:
|
|
@@ -771,7 +771,7 @@ async def _handle_with_cli(
|
|
|
771
771
|
|
|
772
772
|
try:
|
|
773
773
|
result = subprocess.run(
|
|
774
|
-
[cfg.claude_code_bin, "--print", prompt],
|
|
774
|
+
[cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt],
|
|
775
775
|
capture_output=True, text=True, timeout=180, env=env,
|
|
776
776
|
)
|
|
777
777
|
output = (result.stdout or "").strip()
|
|
@@ -164,19 +164,133 @@ async def run_slack_bot(cfg_loader, store) -> None:
|
|
|
164
164
|
if _allowed(event.get("channel", "")):
|
|
165
165
|
await _dispatch(event, client, cfg_loader, store)
|
|
166
166
|
|
|
167
|
+
# ── Watch-channel resolver ────────────────────────────────────────────────
|
|
168
|
+
# Lazily resolves SLACK_WATCH_CHANNELS names → IDs on first message event
|
|
169
|
+
_watch_ids: set[str] = set()
|
|
170
|
+
_watch_resolved = [False] # mutable cell (closure-friendly)
|
|
171
|
+
|
|
172
|
+
async def _resolve_watch_channels(client):
|
|
173
|
+
if _watch_resolved[0]:
|
|
174
|
+
return
|
|
175
|
+
_watch_resolved[0] = True
|
|
176
|
+
if not cfg.slack_watch_channels:
|
|
177
|
+
return
|
|
178
|
+
try:
|
|
179
|
+
resp = await client.conversations_list(
|
|
180
|
+
types="public_channel,private_channel", limit=1000
|
|
181
|
+
)
|
|
182
|
+
name_to_id = {ch["name"]: ch["id"] for ch in (resp.get("channels") or [])}
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.warning("Could not list channels for watch resolution: %s", e)
|
|
185
|
+
name_to_id = {}
|
|
186
|
+
for raw in cfg.slack_watch_channels:
|
|
187
|
+
raw = raw.lstrip("#")
|
|
188
|
+
if raw.upper().startswith("C") and len(raw) >= 9:
|
|
189
|
+
_watch_ids.add(raw.upper())
|
|
190
|
+
logger.info("Sentinel watching channel (ID): %s", raw.upper())
|
|
191
|
+
elif raw in name_to_id:
|
|
192
|
+
_watch_ids.add(name_to_id[raw])
|
|
193
|
+
logger.info("Sentinel watching channel: #%s → %s", raw, name_to_id[raw])
|
|
194
|
+
else:
|
|
195
|
+
logger.warning("Watch channel '%s' not found — skipping", raw)
|
|
196
|
+
|
|
197
|
+
def _is_watch_channel(channel: str) -> bool:
|
|
198
|
+
return bool(_watch_ids) and channel in _watch_ids
|
|
199
|
+
|
|
167
200
|
@app.event("message")
|
|
168
201
|
async def on_message(event, client):
|
|
169
|
-
# DMs
|
|
202
|
+
# DMs from humans → Boss conversation
|
|
170
203
|
if event.get("channel_type") == "im" and not event.get("bot_id"):
|
|
171
204
|
await _dispatch(event, client, cfg_loader, store)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# Passive bot watcher — bot messages in watched channels
|
|
208
|
+
if event.get("bot_id") and event.get("channel"):
|
|
209
|
+
if not _watch_resolved[0]:
|
|
210
|
+
await _resolve_watch_channels(client)
|
|
211
|
+
if _is_watch_channel(event["channel"]):
|
|
212
|
+
await _handle_bot_message(event, client, cfg_loader, store)
|
|
172
213
|
|
|
173
214
|
# ── Start ─────────────────────────────────────────────────────────────────
|
|
174
215
|
|
|
175
216
|
handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
|
|
176
217
|
logger.info("Sentinel Boss connected to Slack (Socket Mode)")
|
|
218
|
+
if cfg.slack_watch_channels:
|
|
219
|
+
logger.info("Sentinel will passively watch for bot errors in: %s", cfg.slack_watch_channels)
|
|
177
220
|
await handler.start_async()
|
|
178
221
|
|
|
179
222
|
|
|
223
|
+
# ── Bot-message watcher ───────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
import re as _re
|
|
226
|
+
import uuid as _uuid
|
|
227
|
+
from pathlib import Path as _Path
|
|
228
|
+
|
|
229
|
+
# Patterns that indicate an error report worth queuing
|
|
230
|
+
_ERROR_RE = _re.compile(
|
|
231
|
+
r"(?:exception|error|failed|failure|fatal|traceback|stacktrace"
|
|
232
|
+
r"|null\s*pointer|out\s*of\s*memory|stack\s*overflow"
|
|
233
|
+
r"|5\d\d\b.*(?:error|failed)" # HTTP 5xx
|
|
234
|
+
r"|WARN|ERROR|FATAL|CRITICAL)",
|
|
235
|
+
_re.IGNORECASE,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _looks_like_error(text: str) -> bool:
|
|
240
|
+
"""Return True if the bot message appears to be an error report."""
|
|
241
|
+
return bool(_ERROR_RE.search(text))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Called when a bot posts in a watched channel.
|
|
247
|
+
If the message looks like an error, auto-queue it as a Sentinel issue.
|
|
248
|
+
"""
|
|
249
|
+
cfg = cfg_loader.sentinel
|
|
250
|
+
bot_id = event.get("bot_id", "")
|
|
251
|
+
channel = event.get("channel", "")
|
|
252
|
+
ts = event.get("ts", "")
|
|
253
|
+
|
|
254
|
+
# Optional bot-ID filter: skip bots not on the allow-list
|
|
255
|
+
if cfg.slack_watch_bot_ids and bot_id not in cfg.slack_watch_bot_ids:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Flatten text (handle block-kit attachments too)
|
|
259
|
+
text = event.get("text", "")
|
|
260
|
+
for att in event.get("attachments", []):
|
|
261
|
+
text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
|
|
262
|
+
text = text.strip()
|
|
263
|
+
|
|
264
|
+
if not text or not _looks_like_error(text):
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# Avoid duplicate issues — skip if we've seen this exact ts from this bot
|
|
268
|
+
dedup_key = f"slack-watch:{channel}:{ts}"
|
|
269
|
+
if store.is_seen_dedup(dedup_key):
|
|
270
|
+
return
|
|
271
|
+
store.mark_seen_dedup(dedup_key)
|
|
272
|
+
|
|
273
|
+
# Write the issue file so Sentinel picks it up on the next poll
|
|
274
|
+
issues_dir = _Path("issues")
|
|
275
|
+
issues_dir.mkdir(exist_ok=True)
|
|
276
|
+
fname = f"slack-watch-{_uuid.uuid4().hex[:8]}.txt"
|
|
277
|
+
content = (
|
|
278
|
+
f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
|
|
279
|
+
f"SLACK_TS: {ts}\n\n"
|
|
280
|
+
f"{text}"
|
|
281
|
+
)
|
|
282
|
+
(issues_dir / fname).write_text(content, encoding="utf-8")
|
|
283
|
+
_Path("SENTINEL_POLL_NOW").touch()
|
|
284
|
+
logger.info("Bot watcher queued issue from %s: %s", bot_id, fname)
|
|
285
|
+
|
|
286
|
+
# React with 👀 so the team knows Sentinel noticed it
|
|
287
|
+
if ts:
|
|
288
|
+
try:
|
|
289
|
+
await client.reactions_add(channel=channel, timestamp=ts, name="eyes")
|
|
290
|
+
except Exception:
|
|
291
|
+
pass # reactions:write scope may not be granted — non-fatal
|
|
292
|
+
|
|
293
|
+
|
|
180
294
|
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
181
295
|
|
|
182
296
|
async def _dispatch(event: dict, client, cfg_loader, store) -> None:
|
|
@@ -273,3 +273,29 @@ class StateStore:
|
|
|
273
273
|
if row:
|
|
274
274
|
return datetime.fromisoformat(row["sent_at"])
|
|
275
275
|
return None
|
|
276
|
+
|
|
277
|
+
# ── Dedup store (used by Slack bot watcher) ───────────────────────────────
|
|
278
|
+
|
|
279
|
+
def is_seen_dedup(self, key: str) -> bool:
|
|
280
|
+
"""Return True if this dedup key has already been processed."""
|
|
281
|
+
with self._conn() as conn:
|
|
282
|
+
conn.execute(
|
|
283
|
+
"CREATE TABLE IF NOT EXISTS dedup "
|
|
284
|
+
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
285
|
+
)
|
|
286
|
+
row = conn.execute(
|
|
287
|
+
"SELECT key FROM dedup WHERE key = ?", (key,)
|
|
288
|
+
).fetchone()
|
|
289
|
+
return row is not None
|
|
290
|
+
|
|
291
|
+
def mark_seen_dedup(self, key: str):
|
|
292
|
+
"""Record a dedup key so it won't be processed again."""
|
|
293
|
+
with self._conn() as conn:
|
|
294
|
+
conn.execute(
|
|
295
|
+
"CREATE TABLE IF NOT EXISTS dedup "
|
|
296
|
+
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
297
|
+
)
|
|
298
|
+
conn.execute(
|
|
299
|
+
"INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
|
|
300
|
+
(key, _now()),
|
|
301
|
+
)
|
|
@@ -27,7 +27,7 @@ WORKSPACE_DIR=./workspace
|
|
|
27
27
|
|
|
28
28
|
# Slack Bot (optional) — Sentinel Boss conversational interface
|
|
29
29
|
# Create a Slack App at api.slack.com, enable Socket Mode, add scopes:
|
|
30
|
-
# app_mentions:read, chat:write, im:history, channels:history, users:read
|
|
30
|
+
# app_mentions:read, chat:write, im:history, channels:history, users:read, reactions:write
|
|
31
31
|
# Then install to workspace and paste both tokens here.
|
|
32
32
|
# SLACK_BOT_TOKEN=xoxb-...
|
|
33
33
|
# SLACK_APP_TOKEN=xapp-...
|
|
@@ -35,3 +35,13 @@ WORKSPACE_DIR=./workspace
|
|
|
35
35
|
# Use the channel name (e.g. devops-sentinel) or the Slack channel ID (e.g. C01AB2CD3EF)
|
|
36
36
|
# Note: requires conversations:read scope on the Slack App if using channel name
|
|
37
37
|
# SLACK_CHANNEL=devops-sentinel
|
|
38
|
+
|
|
39
|
+
# Passive bot watcher (optional) — Sentinel silently monitors these channels for error
|
|
40
|
+
# reports from other bots (e.g. an app alerting bot on a secured server you can't SSH into).
|
|
41
|
+
# When it detects an error message it auto-queues an issue — no @Sentinel mention needed.
|
|
42
|
+
# It reacts with 👀 so the team sees Sentinel noticed it.
|
|
43
|
+
# Comma-separated list of channel names or IDs.
|
|
44
|
+
# SLACK_WATCH_CHANNELS=alerts-1881, errors-prod
|
|
45
|
+
# Optional: only watch messages from specific bot IDs (comma-separated Slack bot IDs).
|
|
46
|
+
# Omit to watch all bots in the channel.
|
|
47
|
+
# SLACK_WATCH_BOT_IDS=B12345678, B87654321
|