@misterhuydo/sentinel 1.0.51 → 1.0.53

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 CHANGED
@@ -1 +1 @@
1
- 2026-03-22T10:41:20.633Z
1
+ 2026-03-22T11:21:17.183Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-22T10:09:55.962Z",
3
- "checkpoint_at": "2026-03-22T10:09:55.963Z",
2
+ "message": "Auto-checkpoint at 2026-03-22T11:32:35.191Z",
3
+ "checkpoint_at": "2026-03-22T11:32:35.192Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.51",
3
+ "version": "1.0.53",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -60,6 +60,7 @@ 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_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
63
64
  project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
64
65
 
65
66
 
@@ -153,6 +154,7 @@ class ConfigLoader:
153
154
  c.slack_bot_token = d.get("SLACK_BOT_TOKEN", "")
154
155
  c.slack_app_token = d.get("SLACK_APP_TOKEN", "")
155
156
  c.slack_channel = d.get("SLACK_CHANNEL", "")
157
+ c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
156
158
  c.project_name = d.get("PROJECT_NAME", "")
157
159
  self.sentinel = c
158
160
 
@@ -89,8 +89,16 @@ def scan_issues(project_dir: Path) -> list[IssueEvent]:
89
89
  issues_dir = project_dir / "issues"
90
90
  issues_dir.mkdir(exist_ok=True)
91
91
 
92
+ def _priority(p: Path) -> tuple:
93
+ # slack-* (human) first, bot-* (bot) last, everything else in between
94
+ if p.name.startswith("slack-"):
95
+ return (0, p.name)
96
+ if p.name.startswith("bot-"):
97
+ return (2, p.name)
98
+ return (1, p.name)
99
+
92
100
  events = []
93
- for f in sorted(issues_dir.iterdir()):
101
+ for f in sorted(issues_dir.iterdir(), key=_priority):
94
102
  if not f.is_file() or f.name.startswith("."):
95
103
  continue
96
104
  if f.suffix.lower() in _BINARY_EXTENSIONS:
@@ -83,6 +83,18 @@ What you can do (tools available):
83
83
  e.g. "fetch logs", "try fetch_log.sh for SSOLWA", "fetch logs with debug",
84
84
  "grab latest logs from STS", "fetch logs without filter"
85
85
 
86
+ 15. watch_bot — Register a Slack bot for passive monitoring. Every message it posts is
87
+ auto-queued as an issue in the bot's registered project.
88
+ ALWAYS requires a project — infer from context or ask the user first.
89
+ e.g. "listen to @alertbot", "watch @bot1 @bot2 for project 1881", "monitor @errorbot"
90
+
91
+ 16. unwatch_bot — Remove a Slack bot from the passive watch list.
92
+ e.g. "stop watching @alertbot", "unwatch @errorbot"
93
+
94
+ 17. list_watched_bots — Show all Slack bots currently being passively monitored and which projects
95
+ they are delivering to.
96
+ e.g. "which bots are you watching?", "list monitored bots"
97
+
86
98
  When someone asks what you can do, what you support, what your capabilities are, or how you can help,
87
99
  reply with a short summary grouped by category:
88
100
 
@@ -109,6 +121,11 @@ reply with a short summary grouped by category:
109
121
  • `pull_repo` — git pull on managed application repos — "pull latest code"
110
122
  • `pull_config` — git pull on Sentinel config dirs — "pull config for elprint"
111
123
 
124
+ *Slack bot watching*
125
+ • `watch_bot` — register a Slack bot for passive monitoring; its messages are auto-queued as issues — "listen to @alertbot"
126
+ • `unwatch_bot` — stop monitoring a bot — "stop watching @errorbot"
127
+ • `list_watched_bots` — show all bots currently being monitored — "which bots are you watching?"
128
+
112
129
  Tone: direct, professional, like a senior engineer who owns the system.
113
130
  Don't pad responses. Don't say "Great question!" or "Certainly!".
114
131
  If you don't know something, use a tool to find out before saying you don't know.
@@ -338,6 +355,60 @@ _TOOLS = [
338
355
  },
339
356
  },
340
357
  },
358
+ {
359
+ "name": "watch_bot",
360
+ "description": (
361
+ "Tell Sentinel to passively monitor a Slack bot — queuing its messages as issues. "
362
+ "Extract all <@UXXXXXX> user IDs from the message and pass them here. "
363
+ "Sentinel verifies each is actually a bot (not a human) before adding to the watch list. "
364
+ "IMPORTANT: a bot watcher is only useful if its issues can be delivered to a project. "
365
+ "Try to infer the project from context (bot name, prior messages, available projects). "
366
+ "If it cannot be determined, do NOT call this tool — instead ask the user which project "
367
+ "the bot's alerts belong to, then call this tool with the project filled in. "
368
+ "Use for: 'listen to @alertbot', 'watch @bot1 @bot2', 'monitor @errorbot'."
369
+ ),
370
+ "input_schema": {
371
+ "type": "object",
372
+ "properties": {
373
+ "user_ids": {
374
+ "type": "array",
375
+ "items": {"type": "string"},
376
+ "description": "Slack user IDs to watch — extract from <@UXXXXXX> patterns in the message",
377
+ },
378
+ "project": {
379
+ "type": "string",
380
+ "description": "Project short name this bot's issues should be routed to (e.g. '1881', 'elprint'). Infer from context or ask user before calling.",
381
+ },
382
+ },
383
+ "required": ["user_ids"],
384
+ },
385
+ },
386
+ {
387
+ "name": "unwatch_bot",
388
+ "description": (
389
+ "Stop Sentinel from monitoring a Slack bot. "
390
+ "Use for: 'stop watching @alertbot', 'unwatch @bot', 'remove @errorbot from watchers'."
391
+ ),
392
+ "input_schema": {
393
+ "type": "object",
394
+ "properties": {
395
+ "user_ids": {
396
+ "type": "array",
397
+ "items": {"type": "string"},
398
+ "description": "Slack user IDs to remove from the watch list",
399
+ },
400
+ },
401
+ "required": ["user_ids"],
402
+ },
403
+ },
404
+ {
405
+ "name": "list_watched_bots",
406
+ "description": (
407
+ "List all Slack bots Sentinel is currently monitoring passively. "
408
+ "Use for: 'who are you watching?', 'which bots are you monitoring?', 'list watched bots'."
409
+ ),
410
+ "input_schema": {"type": "object", "properties": {}},
411
+ },
341
412
  ]
342
413
 
343
414
 
@@ -404,7 +475,7 @@ def _git_pull(path: Path) -> dict:
404
475
 
405
476
  # ── Tool execution ────────────────────────────────────────────────────────────
406
477
 
407
- def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
478
+ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None) -> str:
408
479
  if name == "get_status":
409
480
  hours = int(inputs.get("hours", 24))
410
481
  errors = store.get_recent_errors(hours)
@@ -700,6 +771,89 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
700
771
 
701
772
  return json.dumps({"fetched": len(results), "results": results})
702
773
 
774
+ if name == "watch_bot":
775
+ user_ids = inputs.get("user_ids", [])
776
+ project_arg = inputs.get("project", "").strip()
777
+ if not user_ids:
778
+ return json.dumps({"error": "No user_ids provided"})
779
+
780
+ # Resolve + validate project — required for bot issue routing
781
+ resolved_project = ""
782
+ if project_arg:
783
+ project_dirs = _find_project_dirs(project_arg)
784
+ if not project_dirs:
785
+ all_names = [_read_project_name(d) for d in _find_project_dirs()]
786
+ return json.dumps({
787
+ "error": f"No project found matching '{project_arg}'",
788
+ "available_projects": all_names,
789
+ "action_needed": "Ask the user which project these bot alerts belong to.",
790
+ })
791
+ if len(project_dirs) > 1:
792
+ matches = [_read_project_name(d) for d in project_dirs]
793
+ return json.dumps({
794
+ "error": f"Ambiguous project name '{project_arg}' — matches: {matches}",
795
+ "action_needed": "Ask the user to clarify which project.",
796
+ })
797
+ resolved_project = _read_project_name(project_dirs[0])
798
+ else:
799
+ all_projects = _find_project_dirs()
800
+ if len(all_projects) == 1:
801
+ # Single project in workspace — auto-assign
802
+ resolved_project = _read_project_name(all_projects[0])
803
+ elif all_projects:
804
+ all_names = [_read_project_name(d) for d in all_projects]
805
+ return json.dumps({
806
+ "error": "Cannot determine which project these bot alerts belong to.",
807
+ "available_projects": all_names,
808
+ "action_needed": "Ask the user to specify the project, then retry with project filled in.",
809
+ })
810
+
811
+ results = []
812
+ for uid in user_ids:
813
+ if not slack_client:
814
+ results.append({"user_id": uid, "status": "error", "reason": "no Slack client available"})
815
+ continue
816
+ try:
817
+ info = await slack_client.users_info(user=uid)
818
+ user = info.get("user", {})
819
+ if not user.get("is_bot", False):
820
+ results.append({"user_id": uid, "status": "skipped", "reason": "not a bot — only bots can be watched passively"})
821
+ continue
822
+ bot_name = user.get("real_name") or user.get("name") or uid
823
+ store.add_watched_bot(uid, bot_name, added_by="boss", project_name=resolved_project)
824
+ logger.info("Boss: now watching bot %s (%s) → project '%s'", bot_name, uid, resolved_project or "unset")
825
+ results.append({"user_id": uid, "bot_name": bot_name, "project": resolved_project, "status": "watching"})
826
+ except Exception as e:
827
+ results.append({"user_id": uid, "status": "error", "reason": str(e)})
828
+ return json.dumps({"results": results})
829
+
830
+ if name == "unwatch_bot":
831
+ user_ids = inputs.get("user_ids", [])
832
+ if not user_ids:
833
+ return json.dumps({"error": "No user_ids provided"})
834
+ results = []
835
+ for uid in user_ids:
836
+ removed = store.remove_watched_bot(uid)
837
+ logger.info("Boss: unwatch bot %s → %s", uid, "removed" if removed else "not found")
838
+ results.append({"user_id": uid, "status": "removed" if removed else "not found"})
839
+ return json.dumps({"results": results})
840
+
841
+ if name == "list_watched_bots":
842
+ bots = store.get_watched_bots()
843
+ return json.dumps({
844
+ "count": len(bots),
845
+ "bots": [
846
+ {
847
+ "bot_id": b["bot_id"],
848
+ "bot_name": b["bot_name"],
849
+ "project": b.get("project_name") or "",
850
+ "added_by": b["added_by"],
851
+ "added_at": b["added_at"],
852
+ }
853
+ for b in bots
854
+ ],
855
+ })
856
+
703
857
  return json.dumps({"error": f"unknown tool: {name}"})
704
858
 
705
859
 
@@ -713,10 +867,11 @@ async def _handle_with_cli(
713
867
  history: list,
714
868
  cfg_loader,
715
869
  store,
870
+ slack_client=None,
716
871
  ) -> tuple[str, bool]:
717
872
  """Fallback: use `claude --print` for users without an Anthropic API key."""
718
- status_json = _run_tool("get_status", {"hours": 24}, cfg_loader, store)
719
- prs_json = _run_tool("list_pending_prs", {}, cfg_loader, store)
873
+ status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
874
+ prs_json = await _run_tool("list_pending_prs", {}, cfg_loader, store)
720
875
 
721
876
  # Pre-fetch log search if the message is a search request.
722
877
  # Use quoted strings as the query, or fall back to the full message.
@@ -726,7 +881,7 @@ async def _handle_with_cli(
726
881
  if any(kw in message.lower() for kw in _search_kws):
727
882
  quoted = re.findall(r'"([^"]+)"', message)
728
883
  query = quoted[0] if quoted else message
729
- search_json = _run_tool("search_logs", {"query": query}, cfg_loader, store)
884
+ search_json = await _run_tool("search_logs", {"query": query}, cfg_loader, store)
730
885
 
731
886
  paused = Path("SENTINEL_PAUSE").exists()
732
887
  repos = list(cfg_loader.repos.keys())
@@ -784,7 +939,7 @@ async def _handle_with_cli(
784
939
  action = json.loads(m.group(1))
785
940
  name = action.pop("action", "")
786
941
  if name:
787
- result_str = _run_tool(name, action, cfg_loader, store)
942
+ result_str = await _run_tool(name, action, cfg_loader, store)
788
943
  logger.info("Boss CLI action: %s → %s", name, result_str[:80])
789
944
  except Exception as e:
790
945
  logger.warning("Boss action parse error: %s", e)
@@ -805,6 +960,7 @@ async def handle_message(
805
960
  history: list,
806
961
  cfg_loader,
807
962
  store,
963
+ slack_client=None,
808
964
  ) -> tuple[str, bool]:
809
965
  """
810
966
  Process one user message through the Sentinel Boss (Claude with tool use).
@@ -830,7 +986,7 @@ async def handle_message(
830
986
 
831
987
  api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
832
988
  if not api_key:
833
- return await _handle_with_cli(message, history, cfg_loader, store)
989
+ return await _handle_with_cli(message, history, cfg_loader, store, slack_client=slack_client)
834
990
 
835
991
  client = anthropic.Anthropic(api_key=api_key)
836
992
 
@@ -880,7 +1036,7 @@ async def handle_message(
880
1036
  messages.append({"role": "assistant", "content": response.content})
881
1037
  tool_results = []
882
1038
  for tc in tool_blocks:
883
- result = _run_tool(tc.name, tc.input, cfg_loader, store)
1039
+ result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client)
884
1040
  logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
885
1041
  tool_results.append({
886
1042
  "type": "tool_result",
@@ -164,19 +164,125 @@ 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
+ # ── Passive bot watcher — seed DB from config on startup ─────────────────
168
+ for bot_id_cfg in cfg.slack_watch_bot_ids:
169
+ if bot_id_cfg and not store.is_watched_bot(bot_id_cfg):
170
+ store.add_watched_bot(bot_id_cfg, bot_id_cfg, added_by="config")
171
+ logger.info("Seeded watched bot from config: %s", bot_id_cfg)
172
+
167
173
  @app.event("message")
168
174
  async def on_message(event, client):
169
- # DMs are always allowed regardless of SLACK_CHANNEL restriction
175
+ # DMs from humans Boss conversation
170
176
  if event.get("channel_type") == "im" and not event.get("bot_id"):
171
177
  await _dispatch(event, client, cfg_loader, store)
178
+ return
179
+
180
+ # Passive bot watcher — bot messages from DB-registered bots
181
+ bot_id = event.get("bot_id", "")
182
+ if bot_id and event.get("channel") and store.is_watched_bot(bot_id):
183
+ await _handle_bot_message(event, client, cfg_loader, store)
172
184
 
173
185
  # ── Start ─────────────────────────────────────────────────────────────────
174
186
 
175
187
  handler = AsyncSocketModeHandler(app, cfg.slack_app_token)
176
188
  logger.info("Sentinel Boss connected to Slack (Socket Mode)")
189
+ watched = store.get_watched_bots()
190
+ if watched:
191
+ logger.info("Sentinel passively watching %d bot(s): %s",
192
+ len(watched), [b["bot_name"] for b in watched])
177
193
  await handler.start_async()
178
194
 
179
195
 
196
+ # ── Bot-message watcher ───────────────────────────────────────────────────────
197
+
198
+ import uuid as _uuid
199
+ from pathlib import Path as _Path
200
+
201
+
202
+ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
203
+ """
204
+ Called when a watched bot posts a message.
205
+ Queues the message as an issue in the bot's registered project directory.
206
+ All messages from watched bots are queued — no error pre-filtering.
207
+ """
208
+ bot_id = event.get("bot_id", "")
209
+ channel = event.get("channel", "")
210
+ ts = event.get("ts", "")
211
+
212
+ # Avoid duplicate issues
213
+ dedup_key = f"slack-watch:{channel}:{ts}"
214
+ if store.is_seen_dedup(dedup_key):
215
+ return
216
+ store.mark_seen_dedup(dedup_key)
217
+
218
+ # Flatten text (handle block-kit attachments too)
219
+ text = event.get("text", "")
220
+ for att in event.get("attachments", []):
221
+ text += "\n" + att.get("text", "") + "\n" + att.get("fallback", "")
222
+ text = text.strip()
223
+ if not text:
224
+ return
225
+
226
+ # Find the project this bot is registered to
227
+ bots = store.get_watched_bots()
228
+ bot_info = next((b for b in bots if b["bot_id"] == bot_id), None)
229
+ project_name = (bot_info or {}).get("project_name") or ""
230
+
231
+ # Resolve the project issues directory
232
+ workspace = _Path(cfg_loader.sentinel.workspace_dir).parent
233
+ if project_name:
234
+ # workspace_dir is <sentinel_root>/workspace, project dirs are siblings
235
+ project_dirs = [
236
+ d for d in workspace.iterdir()
237
+ if d.is_dir() and d.name != "workspace"
238
+ and (d / "config" / "sentinel.properties").exists()
239
+ ]
240
+ matched = next(
241
+ (d for d in project_dirs
242
+ if d.name == project_name or
243
+ _read_project_name_from_dir(d) == project_name),
244
+ None,
245
+ )
246
+ issues_dir = (matched / "issues") if matched else (_Path(".") / "issues")
247
+ else:
248
+ issues_dir = _Path(".") / "issues"
249
+
250
+ issues_dir.mkdir(parents=True, exist_ok=True)
251
+
252
+ uid = _uuid.uuid4().hex[:8]
253
+ fname = f"bot-{project_name or 'unknown'}-{uid}.txt"
254
+ content = (
255
+ f"SOURCE: Slack bot {bot_id} in channel {channel}\n"
256
+ f"SLACK_TS: {ts}\n\n"
257
+ f"{text}"
258
+ )
259
+ (issues_dir / fname).write_text(content, encoding="utf-8")
260
+ _Path("SENTINEL_POLL_NOW").touch()
261
+ logger.info("Bot watcher queued issue from %s → %s/%s", bot_id, issues_dir, fname)
262
+
263
+ # React with 👀 so the team knows Sentinel noticed it
264
+ if ts:
265
+ try:
266
+ await client.reactions_add(channel=channel, timestamp=ts, name="eyes")
267
+ except Exception:
268
+ pass # reactions:write scope may not be granted — non-fatal
269
+
270
+
271
+ def _read_project_name_from_dir(project_dir: _Path) -> str:
272
+ """Read PROJECT_NAME from a project's sentinel.properties, or return dir name."""
273
+ props = project_dir / "config" / "sentinel.properties"
274
+ if not props.exists():
275
+ return project_dir.name
276
+ try:
277
+ for line in props.read_text(encoding="utf-8").splitlines():
278
+ line = line.strip()
279
+ if line.upper().startswith("PROJECT_NAME="):
280
+ return line.split("=", 1)[1].partition("#")[0].strip()
281
+ except OSError:
282
+ pass
283
+ return project_dir.name
284
+
285
+
180
286
  # ── Dispatcher ────────────────────────────────────────────────────────────────
181
287
 
182
288
  async def _dispatch(event: dict, client, cfg_loader, store) -> None:
@@ -214,7 +320,8 @@ async def _run_turn(session: _Session, message: str, client, cfg_loader, store)
214
320
 
215
321
  try:
216
322
  reply, is_done = await handle_message(
217
- message, session.history, cfg_loader, store
323
+ message, session.history, cfg_loader, store,
324
+ slack_client=client,
218
325
  )
219
326
  except Exception as e:
220
327
  logger.exception("Sentinel Boss error: %s", e)
@@ -74,10 +74,11 @@ class StateStore:
74
74
  def _migrate(self):
75
75
  """Add new columns to existing DBs without breaking old installs."""
76
76
  migrations = [
77
- ("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
78
- ("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
79
- ("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
80
- ("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
77
+ ("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
78
+ ("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
79
+ ("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
80
+ ("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
81
+ ("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
81
82
  ]
82
83
  with self._conn() as conn:
83
84
  done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
@@ -273,3 +274,68 @@ class StateStore:
273
274
  if row:
274
275
  return datetime.fromisoformat(row["sent_at"])
275
276
  return None
277
+
278
+ # ── Dedup store (used by Slack bot watcher) ───────────────────────────────
279
+
280
+ def is_seen_dedup(self, key: str) -> bool:
281
+ """Return True if this dedup key has already been processed."""
282
+ with self._conn() as conn:
283
+ conn.execute(
284
+ "CREATE TABLE IF NOT EXISTS dedup "
285
+ "(key TEXT PRIMARY KEY, seen_at TEXT)"
286
+ )
287
+ row = conn.execute(
288
+ "SELECT key FROM dedup WHERE key = ?", (key,)
289
+ ).fetchone()
290
+ return row is not None
291
+
292
+ def mark_seen_dedup(self, key: str):
293
+ """Record a dedup key so it won't be processed again."""
294
+ with self._conn() as conn:
295
+ conn.execute(
296
+ "CREATE TABLE IF NOT EXISTS dedup "
297
+ "(key TEXT PRIMARY KEY, seen_at TEXT)"
298
+ )
299
+ conn.execute(
300
+ "INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
301
+ (key, _now()),
302
+ )
303
+
304
+ # ── Watched bots (Slack passive monitor) ─────────────────────────────────
305
+
306
+ def _ensure_watched_bots_table(self, conn):
307
+ conn.execute(
308
+ "CREATE TABLE IF NOT EXISTS watched_bots "
309
+ "(bot_id TEXT PRIMARY KEY, bot_name TEXT, added_by TEXT, added_at TEXT)"
310
+ )
311
+
312
+ def add_watched_bot(self, bot_id: str, bot_name: str, added_by: str = "config", project_name: str = ""):
313
+ with self._conn() as conn:
314
+ self._ensure_watched_bots_table(conn)
315
+ conn.execute(
316
+ "INSERT OR REPLACE INTO watched_bots (bot_id, bot_name, added_by, added_at, project_name) "
317
+ "VALUES (?, ?, ?, ?, ?)",
318
+ (bot_id, bot_name, added_by, _now(), project_name or None),
319
+ )
320
+
321
+ def remove_watched_bot(self, bot_id: str) -> bool:
322
+ """Returns True if a row was deleted."""
323
+ with self._conn() as conn:
324
+ self._ensure_watched_bots_table(conn)
325
+ cur = conn.execute("DELETE FROM watched_bots WHERE bot_id = ?", (bot_id,))
326
+ return cur.rowcount > 0
327
+
328
+ def is_watched_bot(self, bot_id: str) -> bool:
329
+ with self._conn() as conn:
330
+ self._ensure_watched_bots_table(conn)
331
+ return conn.execute(
332
+ "SELECT 1 FROM watched_bots WHERE bot_id = ?", (bot_id,)
333
+ ).fetchone() is not None
334
+
335
+ def get_watched_bots(self) -> list[dict]:
336
+ with self._conn() as conn:
337
+ self._ensure_watched_bots_table(conn)
338
+ rows = conn.execute(
339
+ "SELECT * FROM watched_bots ORDER BY added_at"
340
+ ).fetchall()
341
+ return [dict(r) for r in rows]
@@ -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,10 @@ 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 — seed the watch list on startup with known bot IDs.
40
+ # Sentinel will passively queue every message from these bots as issues (no @mention needed).
41
+ # You can also add bots at runtime: "@Sentinel listen to @alertbot for project 1881"
42
+ # Use "@Sentinel list watched bots" to see the current list.
43
+ # Comma-separated list of Slack bot IDs (starts with B, not U).
44
+ # SLACK_WATCH_BOT_IDS=B12345678, B87654321