@misterhuydo/sentinel 1.5.4 → 1.5.6

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.
Files changed (38) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/minify-map.json +13 -1
  3. package/.cairn/session.json +2 -2
  4. package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
  5. package/.cairn/views/5f5141_main.py +1067 -0
  6. package/.cairn/views/62a614_bundle.js +4 -1
  7. package/.cairn/views/7802b9_cicd_trigger.py +171 -0
  8. package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
  9. package/lib/.cairn/minify-map.json +6 -0
  10. package/lib/.cairn/views/2a85cc_init.js +380 -0
  11. package/lib/.cairn/views/e26996_slack-setup.js +97 -0
  12. package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
  13. package/lib/.cairn/views/fc4a1a_add.js +164 -51
  14. package/lib/init.js +54 -0
  15. package/lib/maven.js +212 -0
  16. package/lib/slack-setup.js +5 -0
  17. package/package.json +1 -1
  18. package/python/requirements.txt +1 -0
  19. package/python/sentinel/.cairn/.cairn-project +0 -0
  20. package/python/sentinel/.cairn/.hint-lock +1 -0
  21. package/python/sentinel/.cairn/session.json +9 -0
  22. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  23. package/python/sentinel/config_loader.py +29 -10
  24. package/python/sentinel/dependency_manager.py +9 -2
  25. package/python/sentinel/git_manager.py +23 -0
  26. package/python/sentinel/issue_watcher.py +7 -1
  27. package/python/sentinel/main.py +353 -8
  28. package/python/sentinel/notify.py +44 -12
  29. package/python/sentinel/repo_task_engine.py +49 -7
  30. package/python/sentinel/sentinel_boss.py +117 -3
  31. package/python/sentinel/slack_bot.py +15 -2
  32. package/python/sentinel/state_store.py +0 -1
  33. package/python/tests/__init__.py +0 -0
  34. package/python/tests/test_config_loader.py +138 -0
  35. package/python/tests/test_log_parser.py +62 -0
  36. package/python/tests/test_repo_router.py +73 -0
  37. package/python/tests/test_smoke.py +96 -0
  38. package/python/tests/test_state_store.py +128 -0
@@ -0,0 +1,3664 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import logging
4
+ import os
5
+ _GITHUB_TOKEN_403_GUIDE = (
6
+ "GitHub returned 403 — your `GITHUB_TOKEN` is blocked by this org's policy.\n\n"
7
+ "*How to fix — create a fine-grained PAT:*\n"
8
+ "1. Go to https://github.com/settings/tokens → *Fine-grained tokens* → *Generate new token*\n"
9
+ "2. Set *Resource owner* to the org (e.g. `exoreaction` or `Opplysningen1881`)\n"
10
+ "3. Set *Repository access* → the specific repo (or all repos in the org)\n"
11
+ "4. Under *Permissions* → enable: `Pull requests` (Read & Write), `Contents` (Read & Write)\n"
12
+ "5. Generate, copy the token, and set it in `config/sentinel.properties`:\n"
13
+ " `GITHUB_TOKEN=github_pat_...`\n"
14
+ "6. Restart Sentinel or send `SIGHUP` to reload config.\n\n"
15
+ "_Note: fine-grained PATs expire after max 1 year — set a reminder to renew._"
16
+ )
17
+ import re
18
+ import subprocess
19
+ import uuid
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Optional
23
+ from .notify import alert_if_rate_limited, slack_alert, is_rate_limited
24
+ logger = logging.getLogger(__name__)
25
+ _SYSTEM =
26
+ _BOSS_MODE_HINTS = {
27
+ "standard": (
28
+ "For requests that are clearly not DevOps tasks (jokes, stories, SVGs, general questions, "
29
+ "creative tasks) — just answer helpfully and naturally. No need to redirect back to "
30
+ "Sentinel's purpose."
31
+ ),
32
+ "strict": (
33
+ "You are a DevOps-only assistant. If the user asks for something unrelated to software "
34
+ "engineering, infrastructure, or this project's operations (e.g. jokes, stories, creative "
35
+ "writing), politely decline and steer back to DevOps topics. Keep it brief and friendly."
36
+ ),
37
+ "fun": (
38
+ "For requests that are clearly not DevOps tasks (jokes, stories, SVGs, creative writing, "
39
+ "trivia, anything fun) — engage fully and enthusiastically. You're a senior engineer who "
40
+ "also happens to be great company. Have fun with it, keep your personality, and don't "
41
+ "over-explain. Then offer to help with DevOps if relevant."
42
+ ),
43
+ }
44
+ def _resolve_system(boss_mode: str = "standard",
45
+ project_name: str = "",
46
+ project_description: str = "",
47
+ other_project_names: list | None = None) -> str:
48
+ hint = _BOSS_MODE_HINTS.get(boss_mode, _BOSS_MODE_HINTS["standard"])
49
+ base = _SYSTEM.replace("{BOSS_MODE_HINT}", hint)
50
+ if not project_name:
51
+ return base
52
+ desc_line = f"\nProject description: {project_description}" if project_description else ""
53
+ others = [n for n in (other_project_names or []) if n.lower() != project_name.lower()]
54
+ if others:
55
+ scope_line = (
56
+ f"\n\nSCOPE ISOLATION (important): You serve ONLY the {project_name} project. "
57
+ f"This Sentinel host also runs instances for: {', '.join(others)}. "
58
+ f"If a user asks about {', '.join(others)} or any other project not in your repos, "
59
+ f"decline and explain you are scoped to {project_name} only. "
60
+ f"Never expose config, logs, errors, or code from other projects."
61
+ )
62
+ else:
63
+ scope_line = (
64
+ f"\n\nSCOPE: You serve ONLY the {project_name} project. "
65
+ f"Decline requests about projects or repos you do not manage."
66
+ )
67
+ identity = (
68
+ f"PROJECT IDENTITY\n"
69
+ f"You are Sentinel Boss for: {project_name}{desc_line}"
70
+ f"{scope_line}\n"
71
+ f"{'=' * 60}\n\n"
72
+ )
73
+ return identity + base
74
+ _TOOLS = [
75
+ {
76
+ "name": "get_status",
77
+ "description": (
78
+ "Get recent errors, fixes applied, fixes pending review, and open PRs. "
79
+ "Use for: 'what happened today?', 'any issues?', 'how are things?', "
80
+ "'what are the open PRs?', 'did sentinel fix anything?'"
81
+ ),
82
+ "input_schema": {
83
+ "type": "object",
84
+ "properties": {
85
+ "hours": {
86
+ "type": "integer",
87
+ "description": "Look-back window in hours (default 24)",
88
+ "default": 24,
89
+ },
90
+ },
91
+ },
92
+ },
93
+ {
94
+ "name": "create_issue",
95
+ "description": (
96
+ "Deliver an issue/task to a Sentinel project instance. "
97
+ "Call this as soon as you have identified: (1) a real issue or task (not a status question "
98
+ "or casual chat), (2) the target project, (3) enough context to describe the problem. "
99
+ "Act immediately — do not ask the user for confirmation first. "
100
+ "Only pause if the target project is genuinely unknown and cannot be inferred."
101
+ ),
102
+ "input_schema": {
103
+ "type": "object",
104
+ "properties": {
105
+ "description": {
106
+ "type": "string",
107
+ "description": "Full problem/task description — include all context the user gave you",
108
+ },
109
+ "project": {
110
+ "type": "string",
111
+ "description": "Project short name (e.g. '1881', 'elprint'). Ask if unclear.",
112
+ },
113
+ "target_repo": {
114
+ "type": "string",
115
+ "description": "Specific repo within the project (omit to let Sentinel auto-route)",
116
+ },
117
+ "support_url": {
118
+ "type": "string",
119
+ "description": "Any URL the user shared (ticket, doc, screenshot link, etc.)",
120
+ },
121
+ "attachments_summary": {
122
+ "type": "string",
123
+ "description": "Summary of any files/screenshots the user attached",
124
+ },
125
+ "findings": {
126
+ "type": "string",
127
+ "description": (
128
+ "A concise, curated summary of evidence directly relevant to this issue — "
129
+ "NOT raw tool output. Include only what the fix engine needs: "
130
+ "key error patterns, affected services, approximate frequency/timestamps, "
131
+ "and 1-3 representative log lines. Omit unrelated results. "
132
+ "Keep under 500 words. Leave empty if no tool results are relevant."
133
+ ),
134
+ },
135
+ },
136
+ "required": ["description"],
137
+ },
138
+ },
139
+ {
140
+ "name": "get_fix_details",
141
+ "description": "Get full details of a specific fix by fingerprint (8+ hex chars).",
142
+ "input_schema": {
143
+ "type": "object",
144
+ "properties": {
145
+ "fingerprint": {"type": "string"},
146
+ },
147
+ "required": ["fingerprint"],
148
+ },
149
+ },
150
+ {
151
+ "name": "retry_issue",
152
+ "description": (
153
+ "Re-queue a previously failed or blocked issue from the archive without requiring the "
154
+ "user to re-type the context. Scans issues/.done/ for the most recent matching file "
155
+ "and re-submits it to Sentinel. "
156
+ "Use when the user says things like: 'retry the last issue', 're-raise the umlaut fix', "
157
+ "'try that again', 'retry Whydah-TypeLib', 'run the last failed fix again'."
158
+ ),
159
+ "input_schema": {
160
+ "type": "object",
161
+ "properties": {
162
+ "project": {
163
+ "type": "string",
164
+ "description": "Project short name (e.g. '1881'). Required.",
165
+ },
166
+ "keyword": {
167
+ "type": "string",
168
+ "description": "Optional keyword to match against archived issue content (e.g. 'umlaut', 'Whydah-TypeLib')",
169
+ },
170
+ },
171
+ "required": ["project"],
172
+ },
173
+ },
174
+ {
175
+ "name": "cancel_issue",
176
+ "description": (
177
+ "Cancel a pending issue that is still waiting in the queue (not yet picked up). "
178
+ "Moves the file to issues/.cancelled/ so Sentinel never processes it. "
179
+ "If the issue is already running it cannot be cancelled — it will block naturally. "
180
+ "Use when the user says: 'stop', 'cancel that', 'don't process X', 'cancel the TypeLib retry'."
181
+ ),
182
+ "input_schema": {
183
+ "type": "object",
184
+ "properties": {
185
+ "project": {
186
+ "type": "string",
187
+ "description": "Project short name (e.g. '1881'). Required.",
188
+ },
189
+ "keyword": {
190
+ "type": "string",
191
+ "description": "Keyword to match the pending issue (repo name, error keyword, etc.)",
192
+ },
193
+ },
194
+ "required": ["project", "keyword"],
195
+ },
196
+ },
197
+ {
198
+ "name": "dev_task",
199
+ "description": (
200
+ "Submit a Sentinel self-improvement task to Patch, the autonomous dev agent. "
201
+ "Patch will explore the Sentinel source code, implement the change, "
202
+ "run syntax checks, and commit. Changes are live immediately. "
203
+ "Use when someone asks: 'add a feature to Sentinel', 'fix a Sentinel bug', "
204
+ "'can you improve Sentinel so that...', 'update Sentinel to support...', "
205
+ "'hey Sentinel, you should be able to...'."
206
+ ),
207
+ "input_schema": {
208
+ "type": "object",
209
+ "properties": {
210
+ "task_type": {
211
+ "type": "string",
212
+ "enum": ["feature", "fix", "refactor", "chore", "ask"],
213
+ "description": "Type of task. Default: feature.",
214
+ },
215
+ "description": {
216
+ "type": "string",
217
+ "description": "Full description of what Patch should implement or fix.",
218
+ },
219
+ "notify_user_ids": {
220
+ "type": "array",
221
+ "items": {"type": "string"},
222
+ "description": (
223
+ "Slack user IDs to ping when the task completes, in addition to the submitter. "
224
+ "Extract from <@USER_ID> mentions in the message. "
225
+ "e.g. if user says 'notify me and @totto', include both user IDs here."
226
+ ),
227
+ },
228
+ },
229
+ "required": ["description"],
230
+ },
231
+ },
232
+ {
233
+ "name": "repo_task",
234
+ "description": (
235
+ "ADMIN ONLY. Submit a feature, fix, or refactor task for a managed repo. "
236
+ "Claude Code will run against the repo's local clone, implement the change, "
237
+ "commit, and push (or open a PR if AUTO_PUBLISH=false). "
238
+ "ALWAYS gather a complete spec first — ask follow-up questions until unambiguous. "
239
+ "Use for: 'add X to elprint-connector-service', 'fix Y in cairn', "
240
+ "'refactor OrderService', any human-requested change to a managed repo."
241
+ ),
242
+ "input_schema": {
243
+ "type": "object",
244
+ "properties": {
245
+ "repo_name": {
246
+ "type": "string",
247
+ "description": "Name of the target repo (must match or fuzzy-match a repo in config/repos/).",
248
+ },
249
+ "task_type": {
250
+ "type": "string",
251
+ "enum": ["feature", "fix", "refactor", "chore"],
252
+ "description": "Type of task. Default: feature.",
253
+ },
254
+ "description": {
255
+ "type": "string",
256
+ "description": "Full, unambiguous description of what to implement or fix. "
257
+ "Include all details gathered from the human.",
258
+ },
259
+ "notify_user_ids": {
260
+ "type": "array",
261
+ "items": {"type": "string"},
262
+ "description": "Extra Slack user IDs to ping on completion (besides the submitter).",
263
+ },
264
+ },
265
+ "required": ["repo_name", "description"],
266
+ },
267
+ },
268
+ {
269
+ "name": "list_pending_prs",
270
+ "description": "List all open Sentinel PRs awaiting admin review.",
271
+ "input_schema": {"type": "object", "properties": {}},
272
+ },
273
+ {
274
+ "name": "check_auth_status",
275
+ "description": (
276
+ "Check Claude authentication health, current rate-limit / usage-limit circuit state, "
277
+ "and fix engine stats for the last 24 h. "
278
+ "Use when someone asks: 'is Claude working?', 'any rate limits?', 'why aren't fixes running?', "
279
+ "'is the API key OK?', 'auth issues?', 'fix engine status'."
280
+ ),
281
+ "input_schema": {"type": "object", "properties": {}},
282
+ },
283
+ {
284
+ "name": "pause_sentinel",
285
+ "description": (
286
+ "Pause ALL Sentinel fix activity immediately. "
287
+ "Use when the engineer says 'pause', 'stop', 'freeze', or 'hold off'."
288
+ ),
289
+ "input_schema": {"type": "object", "properties": {}},
290
+ },
291
+ {
292
+ "name": "resume_sentinel",
293
+ "description": "Resume Sentinel fix activity after a pause.",
294
+ "input_schema": {"type": "object", "properties": {}},
295
+ },
296
+ {
297
+ "name": "list_projects",
298
+ "description": (
299
+ "List all projects (Sentinel instances) in this workspace and the repos "
300
+ "each one manages. Use for: 'what projects do you manage?', 'list projects', "
301
+ "'what repos are configured?', 'show me all projects'."
302
+ ),
303
+ "input_schema": {"type": "object", "properties": {}},
304
+ },
305
+ {
306
+ "name": "search_logs",
307
+ "description": (
308
+ "Search production logs for a keyword or pattern. "
309
+ "When a project or source is specified (or can be inferred), performs a LIVE fetch "
310
+ "via fetch_log.sh with the query as the grep filter — SSHes directly to the server. "
311
+ "Falls back to searching locally-cached log files when no source can be determined. "
312
+ "Use for: 'search logs for illegal PIN in 1881', 'find X in SSOLWA logs', "
313
+ "'what did user Y do?', 'show entries for appid=Z', 'grep logs for X'."
314
+ ),
315
+ "input_schema": {
316
+ "type": "object",
317
+ "properties": {
318
+ "query": {
319
+ "type": "string",
320
+ "description": "Keyword or regex to grep for",
321
+ },
322
+ "source": {
323
+ "type": "string",
324
+ "description": "Log source name to search (partial match against log-config filenames, e.g. 'SSOLWA', '1881'). Leave empty to search all sources.",
325
+ },
326
+ "max_matches": {
327
+ "type": "integer",
328
+ "description": "Max matching lines to return per source (default 30)",
329
+ "default": 30,
330
+ },
331
+ "tail": {
332
+ "type": "integer",
333
+ "description": (
334
+ "Number of log lines to fetch from the server before grepping (default: config value, typically 500). "
335
+ "Increase when the user asks for a longer time window — e.g. 'yesterday up to now' → use 5000-10000. "
336
+ "Higher values take longer but cover more history."
337
+ ),
338
+ },
339
+ },
340
+ "required": ["query"],
341
+ },
342
+ },
343
+ {
344
+ "name": "filter_logs",
345
+ "description": (
346
+ "Search locally-synced log files by keyword or regex — instant, no SSH required. "
347
+ "Use this for fast queries once logs are synced (check with list_projects if unsure). "
348
+ "Supports time-range filtering and case options. "
349
+ "Use for: 'find TryDig in synced logs', 'show errors from last 24h', "
350
+ "'filter logs for appid=X', 'search local logs for Y'."
351
+ ),
352
+ "input_schema": {
353
+ "type": "object",
354
+ "properties": {
355
+ "query": {
356
+ "type": "string",
357
+ "description": "Keyword or regex to search for",
358
+ },
359
+ "source": {
360
+ "type": "string",
361
+ "description": "Log source name (partial match, e.g. 'STS', 'SSOLWA'). Leave empty to search all synced sources.",
362
+ },
363
+ "since_hours": {
364
+ "type": "integer",
365
+ "description": "Only return lines from the last N hours (uses log line timestamps). Omit for all available history.",
366
+ },
367
+ "max_matches": {
368
+ "type": "integer",
369
+ "description": "Max matching lines to return per source file (default 50)",
370
+ "default": 50,
371
+ },
372
+ "case_sensitive": {
373
+ "type": "boolean",
374
+ "description": "Case-sensitive match (default false)",
375
+ "default": False,
376
+ },
377
+ },
378
+ "required": ["query"],
379
+ },
380
+ },
381
+ {
382
+ "name": "trigger_poll",
383
+ "description": (
384
+ "Trigger an immediate log-fetch and error-detection cycle without waiting "
385
+ "for the next scheduled interval. Use when: 'check now', 'run now', "
386
+ "'poll immediately', 'don't wait'."
387
+ ),
388
+ "input_schema": {"type": "object", "properties": {}},
389
+ },
390
+ {
391
+ "name": "get_repo_status",
392
+ "description": (
393
+ "Per-repository breakdown of errors detected and fixes applied. "
394
+ "Use for: 'how is repo X doing?', 'which repo has the most issues?', "
395
+ "'break down by repo'."
396
+ ),
397
+ "input_schema": {
398
+ "type": "object",
399
+ "properties": {
400
+ "hours": {
401
+ "type": "integer",
402
+ "description": "Look-back window in hours (default 24)",
403
+ "default": 24,
404
+ },
405
+ },
406
+ },
407
+ },
408
+ {
409
+ "name": "list_recent_commits",
410
+ "description": (
411
+ "List recent commits made by Sentinel across all managed repos. "
412
+ "Use for: 'what did Sentinel commit?', 'show recent auto-fixes', 'what was changed?'."
413
+ ),
414
+ "input_schema": {
415
+ "type": "object",
416
+ "properties": {
417
+ "limit": {
418
+ "type": "integer",
419
+ "description": "Max commits per repo (default 5)",
420
+ "default": 5,
421
+ },
422
+ },
423
+ },
424
+ },
425
+ {
426
+ "name": "pull_repo",
427
+ "description": (
428
+ "Run git pull on one or all managed repos to fetch latest changes from GitHub. "
429
+ "Use for: 'pull changes', 'git pull', 'update repo X', 'fetch latest code'."
430
+ ),
431
+ "input_schema": {
432
+ "type": "object",
433
+ "properties": {
434
+ "repo": {
435
+ "type": "string",
436
+ "description": "Repo name to pull (omit to pull all configured repos)",
437
+ },
438
+ },
439
+ },
440
+ },
441
+ {
442
+ "name": "pull_config",
443
+ "description": (
444
+ "Run git pull on one or all Sentinel project config directories. "
445
+ "Projects are matched by short name ('1881', 'elprint') or full dir name ('sentinel-1881'). "
446
+ "Use for: 'pull config for 1881', 'update sentinel config', 'pull all configs'."
447
+ ),
448
+ "input_schema": {
449
+ "type": "object",
450
+ "properties": {
451
+ "project": {
452
+ "type": "string",
453
+ "description": "Project short name or dir name to pull (omit for all projects)",
454
+ },
455
+ },
456
+ },
457
+ },
458
+ {
459
+ "name": "fetch_logs",
460
+ "description": (
461
+ "Run fetch_log.sh for one or all configured log sources to pull the latest logs "
462
+ "from remote servers right now. Use for: 'fetch logs', 'run fetch_log.sh', "
463
+ "'grab latest logs from SSOLWA', 'try fetch_log.sh for STS', "
464
+ "'pull logs from server', 'get fresh logs'."
465
+ ),
466
+ "input_schema": {
467
+ "type": "object",
468
+ "properties": {
469
+ "source": {
470
+ "type": "string",
471
+ "description": "Log source name to fetch (partial match, e.g. 'SSOLWA'). Omit to fetch all.",
472
+ },
473
+ "debug": {
474
+ "type": "boolean",
475
+ "description": "Run fetch_log.sh with --debug flag to show SSH/grep details",
476
+ "default": False,
477
+ },
478
+ "tail": {
479
+ "type": "integer",
480
+ "description": "Override TAIL lines (how many log lines to fetch)",
481
+ },
482
+ "grep_filter": {
483
+ "type": "string",
484
+ "description": "Override GREP_FILTER (regex). Pass 'none' to disable filtering.",
485
+ },
486
+ },
487
+ },
488
+ },
489
+ {
490
+ "name": "watch_bot",
491
+ "description": (
492
+ "Tell Sentinel to passively monitor a Slack bot — queuing its messages as issues. "
493
+ "Extract all <@UXXXXXX> user IDs from the message and pass them here. "
494
+ "Sentinel verifies each is actually a bot (not a human) before adding to the watch list. "
495
+ "IMPORTANT: a bot watcher is only useful if its issues can be delivered to a project. "
496
+ "Try to infer the project from context (bot name, prior messages, available projects). "
497
+ "If it cannot be determined, do NOT call this tool — instead ask the user which project "
498
+ "the bot's alerts belong to, then call this tool with the project filled in. "
499
+ "Use for: 'listen to @alertbot', 'watch @bot1 @bot2', 'monitor @errorbot'."
500
+ ),
501
+ "input_schema": {
502
+ "type": "object",
503
+ "properties": {
504
+ "user_ids": {
505
+ "type": "array",
506
+ "items": {"type": "string"},
507
+ "description": "Slack user IDs to watch — extract from <@UXXXXXX> patterns in the message",
508
+ },
509
+ "project": {
510
+ "type": "string",
511
+ "description": "Project short name this bot's issues should be routed to (e.g. '1881', 'elprint'). Infer from context or ask user before calling.",
512
+ },
513
+ },
514
+ "required": ["user_ids"],
515
+ },
516
+ },
517
+ {
518
+ "name": "unwatch_bot",
519
+ "description": (
520
+ "Stop Sentinel from monitoring a Slack bot. "
521
+ "Use for: 'stop watching @alertbot', 'unwatch @bot', 'remove @errorbot from watchers'."
522
+ ),
523
+ "input_schema": {
524
+ "type": "object",
525
+ "properties": {
526
+ "user_ids": {
527
+ "type": "array",
528
+ "items": {"type": "string"},
529
+ "description": "Slack user IDs to remove from the watch list",
530
+ },
531
+ },
532
+ "required": ["user_ids"],
533
+ },
534
+ },
535
+ {
536
+ "name": "list_watched_bots",
537
+ "description": (
538
+ "List all Slack bots Sentinel is currently monitoring passively. "
539
+ "Use for: 'who are you watching?', 'which bots are you monitoring?', 'list watched bots'."
540
+ ),
541
+ "input_schema": {"type": "object", "properties": {}},
542
+ },
543
+ {
544
+ "name": "upgrade_sentinel",
545
+ "description": (
546
+ "Upgrade the Sentinel agent itself: git pull the latest code, update Python deps, "
547
+ "then restart the process. Safe to call at any time — if already up to date, "
548
+ "no restart is triggered. "
549
+ "Use for: 'upgrade sentinel', 'update sentinel', 'upgrade yourself', "
550
+ "'pull latest sentinel code', 'restart sentinel after upgrade'."
551
+ ),
552
+ "input_schema": {"type": "object", "properties": {}},
553
+ },
554
+ {
555
+ "name": "ask_codebase",
556
+ "description": (
557
+ "Ask any natural-language question about a managed codebase — or discuss architecture, "
558
+ "extensions, and new features. Claude Code explores the repo(s) with full file access. "
559
+ "Accepts a repo name (e.g. 'STS', 'TypeLib') OR project name (e.g. '1881', 'Whydah') — "
560
+ "project name queries all its repos. "
561
+ "Use for: 'what does 1881 do?', 'describe the project structure', "
562
+ "'what should we implement next in STS?', 'find security issues in elprint-sales', "
563
+ "'what features could we add to TypeLib?', 'summarise the cairn architecture'. "
564
+ "Use mode=issues to make Claude output structured GitHub issue suggestions "
565
+ "(e.g. 'raise issues for improvements in STS', 'what bugs should we track in 1881?')."
566
+ ),
567
+ "input_schema": {
568
+ "type": "object",
569
+ "properties": {
570
+ "repo": {
571
+ "type": "string",
572
+ "description": "Repo name (e.g. 'STS', 'TypeLib') OR project name (e.g. '1881', 'Whydah')",
573
+ },
574
+ "question": {
575
+ "type": "string",
576
+ "description": "Natural language question, task, or discussion prompt about the codebase",
577
+ },
578
+ "mode": {
579
+ "type": "string",
580
+ "enum": ["explore", "issues"],
581
+ "description": (
582
+ "explore (default): answer freely, describe structure, discuss architecture. "
583
+ "issues: Claude outputs structured GitHub issue suggestions "
584
+ "(title + description + labels) that can be raised on GitHub."
585
+ ),
586
+ },
587
+ },
588
+ "required": ["repo", "question"],
589
+ },
590
+ },
591
+ {
592
+ "name": "restart_project",
593
+ "description": (
594
+ "Stop and restart a specific Sentinel monitoring instance (runs stop.sh then start.sh). "
595
+ "This restarts the Sentinel agent process for that project — it does NOT restart the application itself. "
596
+ "Use when: 'restart sentinel for 1881', 'reload the 1881 monitor', 'restart elprint sentinel'. "
597
+ "Safer than restarting all projects at once."
598
+ ),
599
+ "input_schema": {
600
+ "type": "object",
601
+ "properties": {
602
+ "project": {
603
+ "type": "string",
604
+ "description": "Project short name or dir name (e.g. '1881', 'elprint')",
605
+ },
606
+ },
607
+ "required": ["project"],
608
+ },
609
+ },
610
+ {
611
+ "name": "my_stats",
612
+ "description": (
613
+ "Show the current user's personal Sentinel dashboard: "
614
+ "conversation history length, issues they submitted, and "
615
+ "a summary of Sentinel fix activity (errors caught, fixes applied, "
616
+ "fixes pending PR review, fixes confirmed live, fixes failed). "
617
+ "Use for: 'what have you done for me?', 'show my stats', "
618
+ "'how many issues have been fixed?', 'my history', 'summary', "
619
+ "'what did sentinel fix this week?', 'pending fixes', 'open PRs'."
620
+ ),
621
+ "input_schema": {
622
+ "type": "object",
623
+ "properties": {
624
+ "hours": {
625
+ "type": "integer",
626
+ "description": "Look-back window in hours (default 168 = 7 days)",
627
+ "default": 168,
628
+ },
629
+ },
630
+ },
631
+ },
632
+ {
633
+ "name": "clear_my_history",
634
+ "description": (
635
+ "Clear the current user's conversation history with Sentinel. "
636
+ "After clearing, future sessions start with no memory of past conversations. "
637
+ "Use for: 'clear my history', 'forget our conversation', "
638
+ "'start fresh', 'reset my context', 'wipe my history'."
639
+ ),
640
+ "input_schema": {"type": "object", "properties": {}},
641
+ },
642
+ {
643
+ "name": "tail_log",
644
+ "description": (
645
+ "Fetch the last N lines of a log source's live production logs without any grep filter. "
646
+ "Use when: 'show me recent SSOLWA logs', 'tail STS', 'what's happening in 1881 logs right now', "
647
+ "'show last 100 lines from SSOLWA'. Different from search_logs — no pattern required."
648
+ ),
649
+ "input_schema": {
650
+ "type": "object",
651
+ "properties": {
652
+ "source": {
653
+ "type": "string",
654
+ "description": "Log source name (partial match against log-config filenames, e.g. 'SSOLWA', 'STS')",
655
+ },
656
+ "lines": {
657
+ "type": "integer",
658
+ "description": "Number of recent lines to fetch (default 100)",
659
+ "default": 100,
660
+ },
661
+ },
662
+ "required": ["source"],
663
+ },
664
+ },
665
+ {
666
+ "name": "ask_logs",
667
+ "description": (
668
+ "Ask Claude Code to search and summarize log files for a source. "
669
+ "Claude Code reads the full log history (rsync'd synced logs + rolling window) "
670
+ "and answers the question using its file tools — not just a regex match. "
671
+ "Use for analysis questions that require reading and reasoning over log content. "
672
+ "e.g. 'what errors happened yesterday in SSOLWA?', "
673
+ "'summarize last week of STS logs', "
674
+ "'what's been causing 400s in 1881 logs?', "
675
+ "'any unusual patterns in elprint logs recently?'"
676
+ ),
677
+ "input_schema": {
678
+ "type": "object",
679
+ "properties": {
680
+ "source": {
681
+ "type": "string",
682
+ "description": "Log source name (partial match, e.g. 'SSOLWA', 'STS'). Leave blank to query all sources.",
683
+ },
684
+ "question": {
685
+ "type": "string",
686
+ "description": "Natural language question about the logs",
687
+ },
688
+ },
689
+ "required": ["question"],
690
+ },
691
+ },
692
+ {
693
+ "name": "post_file",
694
+ "description": (
695
+ "Upload a text file directly to the Slack conversation so the user can read or download it. "
696
+ "Use when: output is too large for a chat message, the user asks to 'download', 'export', or "
697
+ "'send as a file', or when formatted content (diffs, logs, CSVs, reports) is clearer as a file. "
698
+ "e.g. 'give me that as a file', 'export the log', 'send me the diff for PR
699
+ "'download the health report', 'export recent errors as CSV'"
700
+ ),
701
+ "input_schema": {
702
+ "type": "object",
703
+ "properties": {
704
+ "content": {
705
+ "type": "string",
706
+ "description": "The full text content of the file to upload",
707
+ },
708
+ "filename": {
709
+ "type": "string",
710
+ "description": "Filename with extension, e.g. 'fix-ab12.diff', 'sentinel-report.txt', 'errors.csv', 'ssolwa.log'",
711
+ },
712
+ "title": {
713
+ "type": "string",
714
+ "description": "Optional display title shown above the file in Slack (defaults to filename)",
715
+ },
716
+ },
717
+ "required": ["content", "filename"],
718
+ },
719
+ },
720
+ {
721
+ "name": "list_all_users",
722
+ "description": (
723
+ "ADMIN ONLY. List all Slack users who have ever talked to Sentinel, "
724
+ "with their issue count and conversation message count. "
725
+ "e.g. 'list all users', 'who has talked to you?', 'show user activity'"
726
+ ),
727
+ "input_schema": {"type": "object", "properties": {}},
728
+ },
729
+ {
730
+ "name": "clear_user_history",
731
+ "description": (
732
+ "ADMIN ONLY. Clear the conversation history for a specific Slack user. "
733
+ "e.g. 'clear history for huy', 'reset bob's conversation'"
734
+ ),
735
+ "input_schema": {
736
+ "type": "object",
737
+ "properties": {
738
+ "user_id": {
739
+ "type": "string",
740
+ "description": "Slack user ID to clear (e.g. U01AB2CD3EF)",
741
+ },
742
+ },
743
+ "required": ["user_id"],
744
+ },
745
+ },
746
+ {
747
+ "name": "reset_fingerprint",
748
+ "description": (
749
+ "ADMIN ONLY. Remove the 24h fix lock for an error fingerprint so Sentinel will retry it "
750
+ "on the next poll cycle. Use when a fix attempt failed and you want to force a retry. "
751
+ "e.g. 'retry fix abc123', 'reset fingerprint abc123de', 'let Sentinel try that error again'"
752
+ ),
753
+ "input_schema": {
754
+ "type": "object",
755
+ "properties": {
756
+ "fingerprint": {
757
+ "type": "string",
758
+ "description": "Error fingerprint hash (8+ hex chars, from get_fix_details or list_all_errors)",
759
+ },
760
+ },
761
+ "required": ["fingerprint"],
762
+ },
763
+ },
764
+ {
765
+ "name": "list_all_errors",
766
+ "description": (
767
+ "ADMIN ONLY. Return the full unfiltered error database — all fingerprints, counts, "
768
+ "sources, and last-seen times. "
769
+ "e.g. 'show all errors', 'full error list', 'dump the error DB'"
770
+ ),
771
+ "input_schema": {
772
+ "type": "object",
773
+ "properties": {
774
+ "hours": {
775
+ "type": "integer",
776
+ "description": "Limit to errors seen in the last N hours (0 = all time)",
777
+ "default": 0,
778
+ },
779
+ },
780
+ },
781
+ },
782
+ {
783
+ "name": "export_db",
784
+ "description": (
785
+ "ADMIN ONLY. Export the full Sentinel state (errors, fixes, PRs, users) as a "
786
+ "downloadable text file posted to Slack. "
787
+ "e.g. 'export the DB', 'download state', 'give me a full report file'"
788
+ ),
789
+ "input_schema": {"type": "object", "properties": {}},
790
+ },
791
+ {
792
+ "name": "refresh_knowledge",
793
+ "description": (
794
+ "Clear the knowledge cache so the next ask_codebase question fetches a fresh answer "
795
+ "instead of using the cached one. Use when the codebase has changed and you want "
796
+ "up-to-date answers. "
797
+ "e.g. 'refresh knowledge for TypeLib', 'clear codebase cache', "
798
+ "'force fresh answer for Java-SDK', 'list what is cached'"
799
+ ),
800
+ "input_schema": {
801
+ "type": "object",
802
+ "properties": {
803
+ "repo": {
804
+ "type": "string",
805
+ "description": "Repo or project name to clear cache for. Omit to clear all cached knowledge.",
806
+ },
807
+ "list_only": {
808
+ "type": "boolean",
809
+ "description": "If true, just list what is cached without deleting anything.",
810
+ },
811
+ },
812
+ },
813
+ },
814
+ {
815
+ "name": "list_prs",
816
+ "description": (
817
+ "ADMIN ONLY. List all tracked pull requests across managed repos. "
818
+ "Shows open PRs waiting for decision, plus recent merges and drops. "
819
+ "Use for: 'show open PRs', 'what PRs are waiting?', 'list renovate PRs', "
820
+ "'what did I merge last week?', 'show all PRs for TypeLib'."
821
+ ),
822
+ "input_schema": {
823
+ "type": "object",
824
+ "properties": {
825
+ "repo": {
826
+ "type": "string",
827
+ "description": "Filter by repo name (partial match). Omit for all repos.",
828
+ },
829
+ "status": {
830
+ "type": "string",
831
+ "enum": ["open", "merged", "closed", "pending"],
832
+ "description": (
833
+ "open = all open PRs, pending = open with no admin decision yet, "
834
+ "merged = merged PRs, closed = dropped/closed without merge. "
835
+ "Omit for all."
836
+ ),
837
+ },
838
+ },
839
+ },
840
+ },
841
+ {
842
+ "name": "drop_pr",
843
+ "description": (
844
+ "ADMIN ONLY. Mark a PR as dropped/rejected — do NOT merge it. "
845
+ "Records who dropped it and when. "
846
+ "Use for: 'drop PR
847
+ "'close without merging PR
848
+ ),
849
+ "input_schema": {
850
+ "type": "object",
851
+ "properties": {
852
+ "repo_name": {
853
+ "type": "string",
854
+ "description": "Repository name (must match a repo in config/repos/)",
855
+ },
856
+ "pr_number": {
857
+ "type": "integer",
858
+ "description": "PR number to drop",
859
+ },
860
+ },
861
+ "required": ["repo_name", "pr_number"],
862
+ },
863
+ },
864
+ {
865
+ "name": "merge_pr",
866
+ "description": (
867
+ "ADMIN ONLY. Merge a branch or PR into the main branch. "
868
+ "ALWAYS call with confirmed=false first to show details for admin review — "
869
+ "never merge without showing the plan first. "
870
+ "Use for Sentinel fix PRs (AUTO_PUBLISH=false), Renovate/external PRs by number, "
871
+ "or any arbitrary branch by name (branch_name). "
872
+ "confirmed=false fetches and shows details; confirmed=true executes the merge. "
873
+ "e.g. 'merge the fix for Whydah-TypeLib', 'merge TypeLib PR
874
+ "'merge branch fix/pin-header-geometry-sync into elprint-connector-service'"
875
+ ),
876
+ "input_schema": {
877
+ "type": "object",
878
+ "properties": {
879
+ "repo_name": {
880
+ "type": "string",
881
+ "description": "Repository name (must match a repo in config/repos/)",
882
+ },
883
+ "fingerprint": {
884
+ "type": "string",
885
+ "description": "Optional 8-char fingerprint to target a specific Sentinel fix PR.",
886
+ },
887
+ "pr_number": {
888
+ "type": "integer",
889
+ "description": "Merge a specific PR by number (e.g. a Renovate PR). "
890
+ "When set, repo_name is still required but fingerprint is ignored.",
891
+ },
892
+ "branch_name": {
893
+ "type": "string",
894
+ "description": "Merge an arbitrary branch into the repo's main branch via "
895
+ "GitHub Merge API. Use when the user gives a branch URL or name "
896
+ "without an associated PR. e.g. 'fix/pin-header-geometry-sync'",
897
+ },
898
+ "confirmed": {
899
+ "type": "boolean",
900
+ "description": (
901
+ "false (default) = fetch details and show plan for review, do NOT merge yet. "
902
+ "true = execute the merge after admin has seen and approved the plan."
903
+ ),
904
+ },
905
+ },
906
+ "required": ["repo_name"],
907
+ },
908
+ },
909
+ {
910
+ "name": "install_tool",
911
+ "description": (
912
+ "ADMIN ONLY. Install a missing build tool (e.g. maven, gradle, node) on this server "
913
+ "using Claude Code with shell execution rights. "
914
+ "Use after Sentinel reports a missing tool error. "
915
+ "e.g. 'install maven', 'install gradle', '@Sentinel install mvn'"
916
+ ),
917
+ "input_schema": {
918
+ "type": "object",
919
+ "properties": {
920
+ "tool_name": {
921
+ "type": "string",
922
+ "description": "The tool to install, e.g. 'maven', 'gradle', 'node'",
923
+ },
924
+ },
925
+ "required": ["tool_name"],
926
+ },
927
+ },
928
+ {
929
+ "name": "list_renovate_prs",
930
+ "description": (
931
+ "List all open Renovate bot pull requests across all managed repos. "
932
+ "Shows package name, version change, Renovate confidence, CI status, age, and merge-readiness. "
933
+ "Use before deciding which Renovate PRs to merge. "
934
+ "e.g. 'show renovate PRs', 'what dependency upgrades are pending?', "
935
+ "'list open renovate pull requests', 'any renovate PRs ready to merge?'"
936
+ ),
937
+ "input_schema": {
938
+ "type": "object",
939
+ "properties": {
940
+ "repo_name": {
941
+ "type": "string",
942
+ "description": "Filter to a specific repo. Omit to scan all managed repos.",
943
+ },
944
+ "ready_only": {
945
+ "type": "boolean",
946
+ "description": "If true, only show PRs where CI passes and there are no conflicts.",
947
+ "default": False,
948
+ },
949
+ },
950
+ },
951
+ },
952
+ {
953
+ "name": "manage_release",
954
+ "description": (
955
+ "Plan and execute repository operations: build, Maven release, dependency updates, "
956
+ "or a full release-and-cascade (release one repo then bump its version in all dependents). "
957
+ "Always presents a confirmation plan before acting. "
958
+ "Examples: 'build Whydah-TypeLib', 'release Whydah-TypeLib', "
959
+ "'update Whydah-Java-SDK to use the latest Whydah-TypeLib', "
960
+ "'trigger a release for Whydah-TypeLib and update all dependents', "
961
+ "'update all repos to use the latest whydah-typelib release'"
962
+ ),
963
+ "input_schema": {
964
+ "type": "object",
965
+ "properties": {
966
+ "operation": {
967
+ "type": "string",
968
+ "enum": ["build", "release", "update_deps", "release_and_cascade"],
969
+ "description": (
970
+ "build: trigger Jenkins build only. "
971
+ "release: trigger Maven Release; auto-cascades if AUTO_PUBLISH=true. "
972
+ "update_deps: update dependency version in target repos (source already released). "
973
+ "release_and_cascade: release source_repo then cascade to all dependents."
974
+ ),
975
+ },
976
+ "source_repo": {
977
+ "type": "string",
978
+ "description": "Repo to build/release, or the repo whose artifact is being updated.",
979
+ },
980
+ "target_repos": {
981
+ "type": "array",
982
+ "items": {"type": "string"},
983
+ "description": "Repos to update. Empty = auto-detect all dependents.",
984
+ },
985
+ "confirmed": {
986
+ "type": "boolean",
987
+ "description": "false = show plan and ask for confirmation. true = execute.",
988
+ "default": False,
989
+ },
990
+ },
991
+ "required": ["operation", "source_repo"],
992
+ },
993
+ },
994
+ {
995
+ "name": "set_maintenance",
996
+ "description": (
997
+ "Confirm that a repo/app is deliberately stopped for maintenance. "
998
+ "Sentinel will silently monitor the health URL and notify when it comes back online. "
999
+ "Use when Sentinel asked if a 502/503 is deliberate. "
1000
+ "e.g. 'yes it's maintenance', 'maintenance ssolwa', 'confirm ssolwa is down for maintenance'"
1001
+ ),
1002
+ "input_schema": {
1003
+ "type": "object",
1004
+ "properties": {
1005
+ "repo_name": {
1006
+ "type": "string",
1007
+ "description": "Repo name as configured (from repo-configs/*.properties)",
1008
+ },
1009
+ "note": {
1010
+ "type": "string",
1011
+ "description": "Optional reason e.g. 'scheduled maintenance', 'dependency update'",
1012
+ },
1013
+ },
1014
+ "required": ["repo_name"],
1015
+ },
1016
+ },
1017
+ {
1018
+ "name": "chain_release",
1019
+ "description": (
1020
+ "Execute a sequential release chain: release repo A, update repo B's dependency on A and release B, "
1021
+ "update repo C's dependency on B and release C, and so on. "
1022
+ "Use when the admin describes a multi-step release pipeline like "
1023
+ "'release TypeLib, then update Java-SDK with new TypeLib and release it, then update Admin-SDK...'. "
1024
+ "Also handles shorter requests like 'release TypeLib and cascade' by inferring the full chain. "
1025
+ "confirmed=false shows the full plan with version numbers; confirmed=true executes all steps in order."
1026
+ ),
1027
+ "input_schema": {
1028
+ "type": "object",
1029
+ "properties": {
1030
+ "chain": {
1031
+ "type": "array",
1032
+ "items": {"type": "string"},
1033
+ "description": (
1034
+ "Ordered list of repo names from first-to-release to last. "
1035
+ "E.g. ['Whydah-TypeLib', 'Whydah-Java-SDK', 'Whydah-Admin-SDK', '1881-SSOLoginWebApp']"
1036
+ ),
1037
+ },
1038
+ "confirmed": {
1039
+ "type": "boolean",
1040
+ "description": "false = show plan only (default); true = execute all steps in sequence",
1041
+ },
1042
+ },
1043
+ "required": ["chain", "confirmed"],
1044
+ },
1045
+ },
1046
+ ]
1047
+ def _workspace_dir() -> Path:
1048
+ return Path(".").resolve()
1049
+ def _short_name(dir_name: str) -> str:
1050
+ if dir_name.startswith("sentinel-"):
1051
+ return dir_name[len("sentinel-"):]
1052
+ return dir_name
1053
+ def _read_project_name(project_dir: Path) -> str:
1054
+ props = project_dir / "config" / "sentinel.properties"
1055
+ if props.exists():
1056
+ try:
1057
+ for line in props.read_text(encoding="utf-8", errors="ignore").splitlines():
1058
+ line = line.strip()
1059
+ if line.startswith("PROJECT_NAME"):
1060
+ _, _, val = line.partition("=")
1061
+ val = val.partition("
1062
+ if val:
1063
+ return val
1064
+ except Exception:
1065
+ pass
1066
+ return _short_name(project_dir.name)
1067
+ def _find_project_dirs(target: str = "") -> list[Path]:
1068
+ current = Path(".").resolve()
1069
+ if not (current / "config").exists():
1070
+ return []
1071
+ if target:
1072
+ t = target.lower()
1073
+ name = _read_project_name(current)
1074
+ if (t not in current.name.lower()
1075
+ and t not in _short_name(current.name).lower()
1076
+ and t not in name.lower()):
1077
+ return []
1078
+ return [current]
1079
+ def _git_pull(path: Path) -> dict:
1080
+ try:
1081
+ r = subprocess.run(
1082
+ ["git", "pull", "--rebase", "origin"],
1083
+ cwd=str(path), capture_output=True, text=True, timeout=60,
1084
+ )
1085
+ last = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else "already up to date"
1086
+ return {"status": "ok" if r.returncode == 0 else "error",
1087
+ "detail": last if r.returncode == 0 else r.stderr.strip()}
1088
+ except Exception as e:
1089
+ return {"status": "error", "detail": str(e)}
1090
+ def _filter_log_sources(props_files: list, source_hint: str) -> list:
1091
+ if not source_hint:
1092
+ return props_files
1093
+ hint = source_hint.lower()
1094
+ def _props_contains(path: Path, key: str, hint: str) -> bool:
1095
+ try:
1096
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
1097
+ stripped = line.strip()
1098
+ if stripped.startswith("
1099
+ continue
1100
+ if stripped.upper().startswith(key + "="):
1101
+ val = stripped.split("=", 1)[1].partition("
1102
+ if hint in val:
1103
+ return True
1104
+ except OSError:
1105
+ pass
1106
+ return False
1107
+ matched = []
1108
+ for p in props_files:
1109
+ if hint in p.stem.lower():
1110
+ matched.append(p)
1111
+ elif _props_contains(p, "REMOTE_SERVICE_USER", hint):
1112
+ matched.append(p)
1113
+ elif _props_contains(p, "HOSTS", hint):
1114
+ matched.append(p)
1115
+ return matched
1116
+ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=None, user_id: str = "", channel: str = "", is_admin: bool = False) -> str:
1117
+ if name == "get_status":
1118
+ hours = int(inputs.get("hours", 24))
1119
+ errors = store.get_recent_errors(hours)
1120
+ fixes = store.get_recent_fixes(hours)
1121
+ prs = store.get_open_prs()
1122
+ top_errors = [
1123
+ {
1124
+ "message": e["message"][:120],
1125
+ "count": e["count"],
1126
+ "source": e["source"],
1127
+ "last_seen": e["last_seen"],
1128
+ }
1129
+ for e in errors[:8]
1130
+ ]
1131
+ return json.dumps({
1132
+ "window_hours": hours,
1133
+ "errors_detected": len(errors),
1134
+ "top_errors": top_errors,
1135
+ "fixes_applied": sum(1 for f in fixes if f["status"] == "applied"),
1136
+ "fixes_pending": sum(1 for f in fixes if f["status"] == "pending"),
1137
+ "fixes_failed": sum(1 for f in fixes if f["status"] == "failed"),
1138
+ "open_prs": [
1139
+ {
1140
+ "repo": p["repo_name"],
1141
+ "branch": p["branch"],
1142
+ "pr_url": p["pr_url"],
1143
+ "age": p.get("timestamp", ""),
1144
+ }
1145
+ for p in prs
1146
+ ],
1147
+ "sentinel_paused": Path("SENTINEL_PAUSE").exists(),
1148
+ })
1149
+ if name == "check_auth_status":
1150
+ import subprocess as _sp
1151
+ from .notify import get_circuit_status
1152
+ cfg = cfg_loader.sentinel
1153
+ has_key = bool(cfg.anthropic_api_key)
1154
+ pro_for_tasks = cfg.claude_pro_for_tasks
1155
+ if pro_for_tasks and has_key:
1156
+ primary, fallback = "claude_pro_oauth", "api_key"
1157
+ elif pro_for_tasks:
1158
+ primary, fallback = "claude_pro_oauth", None
1159
+ else:
1160
+ primary, fallback = "api_key", "claude_pro_oauth" if not has_key else "claude_pro_oauth"
1161
+ cli_ok, cli_version = False, ""
1162
+ try:
1163
+ r = _sp.run(
1164
+ [cfg.claude_code_bin, "--version"],
1165
+ capture_output=True, text=True, timeout=10,
1166
+ )
1167
+ if r.returncode == 0:
1168
+ cli_ok = True
1169
+ cli_version = r.stdout.strip() or r.stderr.strip()
1170
+ except Exception:
1171
+ pass
1172
+ circuits = get_circuit_status()
1173
+ recent = store.get_recent_fixes(hours=24)
1174
+ counts = {"applied": 0, "failed": 0, "skipped": 0, "pending": 0}
1175
+ last_success = None
1176
+ for f in recent:
1177
+ s = f.get("status", "")
1178
+ if s in counts:
1179
+ counts[s] += 1
1180
+ if s == "applied" and not last_success:
1181
+ last_success = f.get("timestamp", "")
1182
+ overall = "healthy"
1183
+ if circuits:
1184
+ overall = "degraded — rate/auth limit active on: " + ", ".join(circuits)
1185
+ elif not cli_ok:
1186
+ overall = "warning — claude CLI not reachable"
1187
+ return json.dumps({
1188
+ "overall": overall,
1189
+ "auth": {
1190
+ "api_key_configured": has_key,
1191
+ "claude_pro_for_tasks": pro_for_tasks,
1192
+ "primary_method": primary,
1193
+ "fallback_method": fallback,
1194
+ },
1195
+ "claude_cli": {"available": cli_ok, "version": cli_version},
1196
+ "rate_limit_circuits": circuits,
1197
+ "fix_engine_24h": {**counts, "last_successful_fix": last_success},
1198
+ })
1199
+ if name == "create_issue":
1200
+ description = inputs["description"]
1201
+ target_repo = inputs.get("target_repo", "")
1202
+ project_arg = inputs.get("project", "")
1203
+ if project_arg:
1204
+ project_dirs = _find_project_dirs(project_arg)
1205
+ if not project_dirs:
1206
+ all_names = [_read_project_name(d) for d in _find_project_dirs()]
1207
+ return json.dumps({
1208
+ "error": f"No project found matching '{project_arg}'",
1209
+ "available_projects": all_names,
1210
+ "action_needed": "Ask the user which project they meant.",
1211
+ })
1212
+ if len(project_dirs) > 1:
1213
+ matches = [_read_project_name(d) for d in project_dirs]
1214
+ return json.dumps({
1215
+ "error": f"Ambiguous project name '{project_arg}' — matches: {matches}",
1216
+ "action_needed": "Ask the user to clarify which project they mean.",
1217
+ })
1218
+ project_dir = project_dirs[0]
1219
+ else:
1220
+ project_dir = Path(".")
1221
+ support_url = inputs.get("support_url", "").strip()
1222
+ attachments_summary = inputs.get("attachments_summary", "").strip()
1223
+ findings = inputs.get("findings", "").strip()
1224
+ issues_dir = project_dir / "issues"
1225
+ issues_dir.mkdir(exist_ok=True)
1226
+ fname = f"slack-{uuid.uuid4().hex[:8]}.txt"
1227
+ submitter_name = store.get_user_name(user_id) if user_id else ""
1228
+ submitter_line = f"SUBMITTED_BY: {submitter_name} ({user_id})" if user_id else ""
1229
+ lines = []
1230
+ if target_repo:
1231
+ lines.append(f"TARGET_REPO: {target_repo}")
1232
+ if submitter_line:
1233
+ lines.append(submitter_line)
1234
+ if support_url:
1235
+ lines.append(f"SUPPORT_URL: {support_url}")
1236
+ lines.append(f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}")
1237
+ lines.append("")
1238
+ lines.append(description)
1239
+ if findings:
1240
+ lines.append(f"\nEVIDENCE (gathered by Sentinel Boss):\n{findings}")
1241
+ if attachments_summary:
1242
+ lines.append(f"\nATTACHMENTS:\n{attachments_summary}")
1243
+ content = "\n".join(lines)
1244
+ (issues_dir / fname).write_text(content, encoding="utf-8")
1245
+ (project_dir / "SENTINEL_POLL_NOW").touch()
1246
+ project_label = _read_project_name(project_dir.resolve()) if project_arg else "this project"
1247
+ logger.info("Boss created issue for %s: %s", project_label, fname)
1248
+ if user_id:
1249
+ try:
1250
+ store.record_submitted_issue(
1251
+ user_id=user_id,
1252
+ user_name=submitter_name,
1253
+ project=project_label,
1254
+ fname=fname,
1255
+ description=description,
1256
+ )
1257
+ except Exception as _rec_err:
1258
+ logger.debug("Boss: could not record submitted issue: %s", _rec_err)
1259
+ return json.dumps({
1260
+ "status": "queued",
1261
+ "project": project_label,
1262
+ "file": fname,
1263
+ "note": f"Delivered to '{project_label}'. Sentinel will process it on the next poll cycle.",
1264
+ })
1265
+ if name == "retry_issue":
1266
+ project_arg = inputs.get("project", "").strip()
1267
+ keyword = inputs.get("keyword", "").strip().lower()
1268
+ project_dirs = _find_project_dirs(project_arg) if project_arg else _find_project_dirs()
1269
+ if not project_dirs:
1270
+ return json.dumps({"error": f"No project found matching '{project_arg}'"})
1271
+ if len(project_dirs) > 1 and project_arg:
1272
+ return json.dumps({"error": f"Ambiguous project '{project_arg}' — matches: {[_read_project_name(d) for d in project_dirs]}"})
1273
+ project_dir = project_dirs[0]
1274
+ issues_base = project_dir / "issues"
1275
+ archive_dirs = [issues_base / d for d in (".done", ".cancelled") if (issues_base / d).exists()]
1276
+ if not archive_dirs:
1277
+ return json.dumps({"error": "No archived issues found — issues/.done/ does not exist"})
1278
+ candidates = sorted(
1279
+ [f for d in archive_dirs for f in d.iterdir() if f.is_file() and not f.name.startswith(".")],
1280
+ key=lambda f: f.stat().st_mtime,
1281
+ reverse=True,
1282
+ )
1283
+ if not candidates:
1284
+ return json.dumps({"error": "No archived issues found in issues/.done/ or issues/.cancelled/"})
1285
+ if keyword:
1286
+ repo_matched = []
1287
+ content_matched = []
1288
+ for f in candidates:
1289
+ try:
1290
+ content = f.read_text(encoding="utf-8", errors="replace")
1291
+ content_lower = content.lower()
1292
+ for line in content.splitlines():
1293
+ if line.strip().upper().startswith("TARGET_REPO"):
1294
+ repo_val = line.split(":", 1)[-1].split("=", 1)[-1].strip().lower()
1295
+ if keyword.lower() in repo_val:
1296
+ repo_matched.append(f)
1297
+ break
1298
+ else:
1299
+ if keyword.lower() in content_lower:
1300
+ content_matched.append(f)
1301
+ except OSError:
1302
+ pass
1303
+ matched = repo_matched if repo_matched else content_matched
1304
+ if not matched:
1305
+ return json.dumps({"error": f"No archived issues match keyword '{keyword}'"})
1306
+ candidates = matched
1307
+ source_file = candidates[0]
1308
+ content = source_file.read_text(encoding="utf-8", errors="replace")
1309
+ import hashlib as _hashlib
1310
+ _target_repo = ""
1311
+ _message = ""
1312
+ _body_lines = []
1313
+ _in_body = False
1314
+ _META_UPPER = ("TARGET_REPO:", "SUBMITTED_BY:", "SUBMITTED_AT:", "SUPPORT_URL:")
1315
+ for _line in content.splitlines():
1316
+ _s = _line.strip()
1317
+ if not _in_body:
1318
+ if _s.upper().startswith("TARGET_REPO"):
1319
+ _target_repo = _s.split(":", 1)[-1].split("=", 1)[-1].strip()
1320
+ elif any(_s.upper().startswith(_p) for _p in _META_UPPER) or not _s:
1321
+ pass
1322
+ else:
1323
+ _in_body = True
1324
+ _body_lines.append(_line)
1325
+ else:
1326
+ _body_lines.append(_line)
1327
+ _message = next((l.strip() for l in _body_lines if l.strip()), source_file.name)
1328
+ _fp_raw = f"issue:{_target_repo}:{_message[:200]}"
1329
+ _fp = _hashlib.sha1(_fp_raw.encode()).hexdigest()[:16]
1330
+ if store:
1331
+ try:
1332
+ with store._conn() as _c:
1333
+ _row = _c.execute(
1334
+ "SELECT status, pr_url, commit_hash FROM fixes "
1335
+ "WHERE fingerprint=? ORDER BY timestamp DESC LIMIT 1",
1336
+ (_fp,),
1337
+ ).fetchone()
1338
+ if _row:
1339
+ _status = _row["status"]
1340
+ if _status in ("merged", "applied"):
1341
+ _commit = _row["commit_hash"] or ""
1342
+ return json.dumps({
1343
+ "error": (
1344
+ f"Already fixed — this issue was resolved "
1345
+ + (f"in commit `{_commit[:8]}`" if _commit else "successfully")
1346
+ + f". Status: `{_status}`. "
1347
+ f"If the problem recurred, describe it as a new issue."
1348
+ )
1349
+ })
1350
+ if _status == "pending":
1351
+ _pr = _row["pr_url"] or ""
1352
+ return json.dumps({
1353
+ "error": (
1354
+ f"There is already an open PR for this issue"
1355
+ + (f": {_pr}" if _pr else "")
1356
+ + ". Merge or close it before retrying."
1357
+ )
1358
+ })
1359
+ except Exception as _e:
1360
+ logger.debug("retry_issue: state_store guard failed (non-fatal): %s", _e)
1361
+ issues_dir = project_dir / "issues"
1362
+ issues_dir.mkdir(exist_ok=True)
1363
+ fname = f"retry-{source_file.stem[-8:]}-{uuid.uuid4().hex[:6]}.txt"
1364
+ (issues_dir / fname).write_text(content, encoding="utf-8")
1365
+ (project_dir / "SENTINEL_POLL_NOW").touch()
1366
+ project_label = _read_project_name(project_dir.resolve())
1367
+ logger.info("Boss retry_issue: re-queued '%s' as '%s' for %s", source_file.name, fname, project_label)
1368
+ return json.dumps({
1369
+ "status": "re-queued",
1370
+ "project": project_label,
1371
+ "original_file": source_file.name,
1372
+ "new_file": fname,
1373
+ "note": f"Re-submitted '{source_file.name}' to '{project_label}'. Poll triggered.",
1374
+ })
1375
+ if name == "cancel_issue":
1376
+ import re as _re
1377
+ project_arg = inputs.get("project", "").strip()
1378
+ keyword = inputs.get("keyword", "").strip()
1379
+ project_dirs = _find_project_dirs(project_arg) if project_arg else _find_project_dirs()
1380
+ if not project_dirs:
1381
+ return json.dumps({"error": f"No project found matching '{project_arg}'"})
1382
+ if len(project_dirs) > 1 and project_arg:
1383
+ return json.dumps({"error": f"Ambiguous project '{project_arg}' — matches: {[_read_project_name(d) for d in project_dirs]}"})
1384
+ project_dir = project_dirs[0]
1385
+ issues_dir = project_dir / "issues"
1386
+ if not issues_dir.exists():
1387
+ return json.dumps({"error": "No issues/ directory found for this project"})
1388
+ from .issue_watcher import cancel_issue as _cancel_issue
1389
+ result = _cancel_issue(issues_dir, keyword)
1390
+ if "error" in result:
1391
+ return json.dumps(result)
1392
+ cancelled_file = (issues_dir / ".cancelled" / result["cancelled"])
1393
+ submitter_uid = ""
1394
+ if cancelled_file.exists():
1395
+ for line in cancelled_file.read_text(encoding="utf-8", errors="replace").splitlines():
1396
+ if line.strip().upper().startswith("SUBMITTED_BY:"):
1397
+ m = _re.search(r"\((\w+)\)", line)
1398
+ if m:
1399
+ submitter_uid = m.group(1)
1400
+ break
1401
+ if submitter_uid:
1402
+ if user_id != submitter_uid and not is_admin:
1403
+ import shutil as _shutil
1404
+ _shutil.move(str(cancelled_file), str(issues_dir / result["cancelled"]))
1405
+ return json.dumps({
1406
+ "error": (
1407
+ f"Permission denied — this issue was submitted by <@{submitter_uid}>. "
1408
+ f"Only they or an admin can cancel it."
1409
+ )
1410
+ })
1411
+ else:
1412
+ if not is_admin:
1413
+ import shutil as _shutil
1414
+ _shutil.move(str(cancelled_file), str(issues_dir / result["cancelled"]))
1415
+ return json.dumps({"error": "This issue was not submitted via Slack — only admins can cancel it."})
1416
+ project_label = _read_project_name(project_dir.resolve())
1417
+ logger.info("Boss cancel_issue: cancelled '%s' for %s by user %s", result["cancelled"], project_label, user_id)
1418
+ return json.dumps({
1419
+ "status": "cancelled",
1420
+ "project": project_label,
1421
+ "file": result["cancelled"],
1422
+ "note": "Moved to issues/.cancelled/ — Sentinel will not process it.",
1423
+ })
1424
+ if name == "dev_task":
1425
+ if not is_admin:
1426
+ return json.dumps({"error": "Only admins can submit dev tasks to Patch."})
1427
+ description = inputs.get("description", "").strip()
1428
+ if not description:
1429
+ return json.dumps({"error": "description is required"})
1430
+ task_type = inputs.get("task_type", "feature").strip()
1431
+ if task_type not in ("feature", "fix", "refactor", "chore", "ask"):
1432
+ task_type = "feature"
1433
+ _project_dirs = _find_project_dirs()
1434
+ if not _project_dirs:
1435
+ return json.dumps({"error": "No project directory found — cannot drop dev task."})
1436
+ _dev_project_dir = _project_dirs[0]
1437
+ from .sentinel_dev import drop_escalation as _drop_task
1438
+ from datetime import datetime as _dt, timezone as _tz
1439
+ import uuid as _uuid
1440
+ dev_tasks_dir = _dev_project_dir / "dev-tasks"
1441
+ dev_tasks_dir.mkdir(exist_ok=True)
1442
+ ts = int(__import__("time").time())
1443
+ fname = f"slack-{_uuid.uuid4().hex[:8]}-{ts}.txt"
1444
+ fpath = dev_tasks_dir / fname
1445
+ notify_ids = inputs.get("notify_user_ids") or []
1446
+ if isinstance(notify_ids, list):
1447
+ notify_ids = [u for u in notify_ids if u and u != user_id]
1448
+ lines = [
1449
+ f"TYPE: {task_type}",
1450
+ f"SUBMITTED_BY: <@{user_id}> ({user_id})",
1451
+ f"SOURCE: boss",
1452
+ f"SUBMITTED_AT: {_dt.now(_tz.utc).isoformat()}",
1453
+ ]
1454
+ if notify_ids:
1455
+ lines.append(f"NOTIFY: {','.join(notify_ids)}")
1456
+ lines += ["", description]
1457
+ fpath.write_text("\n".join(lines), encoding="utf-8")
1458
+ logger.info("Boss dev_task: dropped %s for user %s (type=%s)", fname, user_id, task_type)
1459
+ project_label = _read_project_name(_dev_project_dir.resolve())
1460
+ return json.dumps({
1461
+ "status": "queued",
1462
+ "project": project_label,
1463
+ "file": fname,
1464
+ "task_type": task_type,
1465
+ "note": (
1466
+ "Dev task queued — Patch will pick it up on the next poll cycle "
1467
+ "and post progress to this channel."
1468
+ ),
1469
+ })
1470
+ if name == "repo_task":
1471
+ if not is_admin:
1472
+ return json.dumps({"error": "Only admins can submit repo tasks."})
1473
+ repo_name = inputs.get("repo_name", "").strip()
1474
+ description = inputs.get("description", "").strip()
1475
+ if not repo_name:
1476
+ return json.dumps({"error": "repo_name is required"})
1477
+ if not description:
1478
+ return json.dumps({"error": "description is required"})
1479
+ task_type = inputs.get("task_type", "feature").strip()
1480
+ if task_type not in ("feature", "fix", "refactor", "chore"):
1481
+ task_type = "feature"
1482
+ if repo_name not in cfg_loader.repos:
1483
+ for rname in cfg_loader.repos:
1484
+ if repo_name.lower() in rname.lower() or rname.lower() in repo_name.lower():
1485
+ repo_name = rname
1486
+ break
1487
+ if repo_name not in cfg_loader.repos:
1488
+ return json.dumps({
1489
+ "error": f"No repo matching '{repo_name}' found.",
1490
+ "available_repos": list(cfg_loader.repos.keys()),
1491
+ })
1492
+ notify_ids = inputs.get("notify_user_ids") or []
1493
+ if isinstance(notify_ids, list):
1494
+ notify_ids = [u for u in notify_ids if u and u != user_id]
1495
+ _project_dirs = _find_project_dirs()
1496
+ if not _project_dirs:
1497
+ return json.dumps({"error": "No project directory found."})
1498
+ from .repo_task_engine import drop_repo_task as _drop_repo_task
1499
+ task_file = _drop_repo_task(
1500
+ _project_dirs[0],
1501
+ repo_name=repo_name,
1502
+ task_type=task_type,
1503
+ description=description,
1504
+ submitter_user_id=user_id,
1505
+ notify_user_ids=notify_ids,
1506
+ )
1507
+ logger.info("Boss repo_task: dropped %s for user %s (repo=%s)", task_file.name, user_id, repo_name)
1508
+ return json.dumps({
1509
+ "status": "queued",
1510
+ "repo": repo_name,
1511
+ "task_type": task_type,
1512
+ "file": task_file.name,
1513
+ "note": f"Task queued for `{repo_name}` — Claude will implement and post progress here.",
1514
+ })
1515
+ if name == "get_fix_details":
1516
+ fp = inputs["fingerprint"]
1517
+ fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
1518
+ if not fix:
1519
+ recent = store.get_recent_fixes(hours=72)
1520
+ fix = next((f for f in recent if f.get("fingerprint", "").startswith(fp)), None)
1521
+ return json.dumps(fix or {"error": "not found"})
1522
+ if name == "list_pending_prs":
1523
+ prs = store.get_open_prs()
1524
+ return json.dumps({
1525
+ "count": len(prs),
1526
+ "open_prs": [
1527
+ {
1528
+ "repo": p["repo_name"],
1529
+ "branch": p["branch"],
1530
+ "pr_url": p["pr_url"],
1531
+ "timestamp": p.get("timestamp", ""),
1532
+ }
1533
+ for p in prs
1534
+ ],
1535
+ })
1536
+ if name == "pause_sentinel":
1537
+ Path("SENTINEL_PAUSE").touch()
1538
+ logger.info("Boss: SENTINEL_PAUSE created")
1539
+ return json.dumps({"status": "paused"})
1540
+ if name == "resume_sentinel":
1541
+ p = Path("SENTINEL_PAUSE")
1542
+ if p.exists():
1543
+ p.unlink()
1544
+ logger.info("Boss: SENTINEL_PAUSE removed")
1545
+ return json.dumps({"status": "resumed"})
1546
+ if name == "list_projects":
1547
+ projects = []
1548
+ for d in _find_project_dirs():
1549
+ repo_cfg_dir = d / "config" / "repo-configs"
1550
+ repos_in_project = []
1551
+ if repo_cfg_dir.exists():
1552
+ for p in sorted(repo_cfg_dir.glob("*.properties")):
1553
+ if p.name.startswith("_"):
1554
+ continue
1555
+ repo_url = ""
1556
+ for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
1557
+ if line.startswith("REPO_URL"):
1558
+ repo_url = line.split("=", 1)[-1].strip()
1559
+ break
1560
+ repos_in_project.append({"repo": p.stem, "url": repo_url})
1561
+ projects.append({
1562
+ "project": _read_project_name(d),
1563
+ "dir": d.name,
1564
+ "running": (d / "sentinel.pid").exists(),
1565
+ "this": d.resolve() == Path(".").resolve(),
1566
+ "repos": repos_in_project,
1567
+ })
1568
+ return json.dumps({"projects": projects})
1569
+ if name == "search_logs":
1570
+ query = inputs.get("query", "")
1571
+ source = inputs.get("source", "").lower()
1572
+ max_matches = int(inputs.get("max_matches", 30))
1573
+ tail_override = inputs.get("tail")
1574
+ synced_base = Path("workspace/synced")
1575
+ if synced_base.exists():
1576
+ log_cfg_dir_s = Path("config") / "log-configs"
1577
+ candidate_sources = (
1578
+ [p.stem for p in _filter_log_sources(sorted(log_cfg_dir_s.glob("*.properties")), source)]
1579
+ if log_cfg_dir_s.exists() else
1580
+ [d.name for d in sorted(synced_base.iterdir()) if d.is_dir()]
1581
+ )
1582
+ synced_results = []
1583
+ try:
1584
+ qpat_s = re.compile(query, re.IGNORECASE)
1585
+ except re.error:
1586
+ qpat_s = re.compile(re.escape(query), re.IGNORECASE)
1587
+ for src_name in candidate_sources:
1588
+ src_dir = synced_base / src_name
1589
+ if not src_dir.is_dir():
1590
+ continue
1591
+ for log_file in sorted(src_dir.glob("*")):
1592
+ try:
1593
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
1594
+ matches = [ln[:300] for ln in lines if qpat_s.search(ln)][:max_matches]
1595
+ if matches:
1596
+ synced_results.append({"source": src_name, "file": log_file.name, "matches": matches})
1597
+ except Exception:
1598
+ pass
1599
+ if synced_results:
1600
+ total = sum(len(r["matches"]) for r in synced_results)
1601
+ return json.dumps({
1602
+ "query": query,
1603
+ "mode": "synced",
1604
+ "total_matches": total,
1605
+ "results": synced_results,
1606
+ "note": "Results from locally-synced files. No SSH needed.",
1607
+ })
1608
+ script = Path(__file__).resolve().parent.parent / "scripts" / "fetch_log.sh"
1609
+ log_cfg_dir = Path("config") / "log-configs"
1610
+ if script.exists() and log_cfg_dir.exists():
1611
+ props_files = _filter_log_sources(sorted(log_cfg_dir.glob("*.properties")), source)
1612
+ if props_files:
1613
+ live_results = []
1614
+ for props in props_files:
1615
+ env = os.environ.copy()
1616
+ env["GREP_FILTER"] = query
1617
+ if tail_override:
1618
+ env["TAIL"] = str(tail_override)
1619
+ try:
1620
+ r = subprocess.run(
1621
+ ["bash", str(script), str(props)],
1622
+ capture_output=True, text=True, timeout=60, env=env,
1623
+ )
1624
+ try:
1625
+ _qpat = re.compile(query, re.IGNORECASE)
1626
+ except re.error:
1627
+ _qpat = re.compile(re.escape(query), re.IGNORECASE)
1628
+ lines = (r.stdout or "").strip().splitlines()
1629
+ matches = [ln[:300] for ln in lines if _qpat.search(ln)][:max_matches]
1630
+ if matches:
1631
+ live_results.append({"source": props.stem, "matches": matches})
1632
+ logger.info("Boss search_logs live %s rc=%d found=%d", props.stem, r.returncode, len(matches))
1633
+ except subprocess.TimeoutExpired:
1634
+ live_results.append({"source": props.stem, "error": "timed out"})
1635
+ except Exception as e:
1636
+ live_results.append({"source": props.stem, "error": str(e)})
1637
+ total = sum(len(r.get("matches", [])) for r in live_results)
1638
+ return json.dumps({
1639
+ "query": query,
1640
+ "mode": "live",
1641
+ "total_matches": total,
1642
+ "results": live_results,
1643
+ "note": (
1644
+ "Results already include a per-source breakdown. "
1645
+ "Do NOT call search_logs again with a source filter to 'refine' — "
1646
+ "use these results directly."
1647
+ ) if total > 0 else None,
1648
+ })
1649
+ fetched_dir = Path("workspace/fetched")
1650
+ if not fetched_dir.exists():
1651
+ return json.dumps({
1652
+ "error": "No fetched logs found and fetch_log.sh unavailable",
1653
+ "note": "This is a config/setup problem, not a 'no results' answer.",
1654
+ })
1655
+ try:
1656
+ pattern = re.compile(query, re.IGNORECASE)
1657
+ except re.error as e:
1658
+ return json.dumps({"error": f"Invalid regex: {e}"})
1659
+ results = []
1660
+ for log_file in sorted(fetched_dir.glob("*.log")):
1661
+ if source and source not in log_file.name.lower():
1662
+ continue
1663
+ try:
1664
+ lines = log_file.read_text(encoding="utf-8", errors="ignore").splitlines()
1665
+ matches = [
1666
+ {"line": i + 1, "text": line[:300]}
1667
+ for i, line in enumerate(lines)
1668
+ if pattern.search(line)
1669
+ ][:max_matches]
1670
+ if matches:
1671
+ results.append({"file": log_file.name, "matches": matches})
1672
+ except Exception:
1673
+ pass
1674
+ total = sum(len(r["matches"]) for r in results)
1675
+ files_searched = len(list(fetched_dir.glob("*.log")))
1676
+ result = {
1677
+ "query": query,
1678
+ "mode": "cached",
1679
+ "total_matches": total,
1680
+ "files_searched": files_searched,
1681
+ "results": results,
1682
+ }
1683
+ if files_searched == 0:
1684
+ result["warning"] = (
1685
+ "Source name not recognised in cached files — this is a lookup failure, not 'no results'. "
1686
+ "If you already have results from a broader search_logs call, use those. Stop retrying."
1687
+ )
1688
+ return json.dumps(result)
1689
+ if name == "filter_logs":
1690
+ import re as _re
1691
+ from collections import Counter as _Counter
1692
+ from datetime import datetime as _datetime, timedelta, timezone as _tz
1693
+ _EXC_PAT = _re.compile(r'([A-Z][a-zA-Z]+(?:Exception|Error|Failure|Fault|Warning))')
1694
+ _LVL_PAT = _re.compile(r'\b(ERROR|WARN(?:ING)?|CRITICAL|FATAL|SEVERE)\b', _re.IGNORECASE)
1695
+ def _signature(line):
1696
+ exc = _EXC_PAT.search(line)
1697
+ if exc:
1698
+ return exc.group(1)
1699
+ m = _LVL_PAT.search(line)
1700
+ if m:
1701
+ after = line[m.end():].strip()
1702
+ token = after.split()[0].rstrip(':.,') if after.split() else ''
1703
+ if token and len(token) > 2:
1704
+ return m.group(1).upper() + ' ' + token[:40]
1705
+ return line.strip()[:40]
1706
+ query_f = inputs.get("query", "")
1707
+ source_f = inputs.get("source", "").lower()
1708
+ since_hours = inputs.get("since_hours")
1709
+ max_matches = int(inputs.get("max_matches", 300))
1710
+ case_flag = 0 if inputs.get("case_sensitive") else _re.IGNORECASE
1711
+ try:
1712
+ pat = _re.compile(query_f, case_flag)
1713
+ except _re.error as e:
1714
+ return json.dumps({"error": f"Invalid regex: {e}"})
1715
+ synced_base = Path("workspace/synced")
1716
+ if not synced_base.exists():
1717
+ return json.dumps({
1718
+ "error": "No synced logs found.",
1719
+ "hint": "Log sync runs every SYNC_INTERVAL_SECONDS (default 300s). "
1720
+ "If just started, wait a minute then try again.",
1721
+ })
1722
+ cutoff = None
1723
+ if since_hours:
1724
+ cutoff = _datetime.now(_tz.utc) - timedelta(hours=int(since_hours))
1725
+ if source_f:
1726
+ src_dirs = [d for d in sorted(synced_base.iterdir())
1727
+ if d.is_dir() and source_f in d.name.lower()]
1728
+ else:
1729
+ src_dirs = [d for d in sorted(synced_base.iterdir()) if d.is_dir()]
1730
+ if not src_dirs:
1731
+ available = [d.name for d in synced_base.iterdir() if d.is_dir()]
1732
+ return json.dumps({
1733
+ "error": f"No synced source matching '{source_f}'",
1734
+ "available_sources": available,
1735
+ })
1736
+ results = []
1737
+ total_matches = 0
1738
+ for src_dir in src_dirs:
1739
+ for log_file in sorted(src_dir.glob("*")):
1740
+ try:
1741
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
1742
+ matches = []
1743
+ for line in lines:
1744
+ if not pat.search(line):
1745
+ continue
1746
+ if cutoff:
1747
+ from .log_fetcher import _parse_line_ts
1748
+ ts = _parse_line_ts(line)
1749
+ if ts and ts < cutoff:
1750
+ continue
1751
+ matches.append(line[:300])
1752
+ if len(matches) >= max_matches:
1753
+ break
1754
+ if matches:
1755
+ results.append({
1756
+ "source": src_dir.name,
1757
+ "file": log_file.name,
1758
+ "matches": matches,
1759
+ })
1760
+ total_matches += len(matches)
1761
+ except Exception:
1762
+ pass
1763
+ if not results:
1764
+ return json.dumps({
1765
+ "query": query_f,
1766
+ "total_matches": 0,
1767
+ "sources_searched": [d.name for d in src_dirs],
1768
+ "note": "No matches found in synced logs.",
1769
+ })
1770
+ try:
1771
+ pat = _re.compile(query_f, case_flag)
1772
+ except _re.error as e:
1773
+ return json.dumps({"error": f"Invalid regex: {e}"})
1774
+ synced_base = Path("workspace/synced")
1775
+ if not synced_base.exists():
1776
+ return json.dumps({
1777
+ "error": "No synced logs found.",
1778
+ "hint": "Log sync runs every SYNC_INTERVAL_SECONDS (default 300s). "
1779
+ "If just started, wait a minute then try again.",
1780
+ })
1781
+ cutoff = None
1782
+ if since_hours:
1783
+ cutoff = _datetime.now(_tz.utc) - timedelta(hours=int(since_hours))
1784
+ if source_f:
1785
+ src_dirs = [d for d in sorted(synced_base.iterdir())
1786
+ if d.is_dir() and source_f in d.name.lower()]
1787
+ else:
1788
+ src_dirs = [d for d in sorted(synced_base.iterdir()) if d.is_dir()]
1789
+ if not src_dirs:
1790
+ available = [d.name for d in synced_base.iterdir() if d.is_dir()]
1791
+ return json.dumps({
1792
+ "error": f"No synced source matching '{source_f}'",
1793
+ "available_sources": available,
1794
+ })
1795
+ all_matches = []
1796
+ sources_hit = set()
1797
+ for src_dir in src_dirs:
1798
+ for log_file in sorted(src_dir.glob("*")):
1799
+ try:
1800
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
1801
+ for line in lines:
1802
+ if not pat.search(line):
1803
+ continue
1804
+ if cutoff:
1805
+ from .log_fetcher import _parse_line_ts
1806
+ ts = _parse_line_ts(line)
1807
+ if ts and ts < cutoff:
1808
+ continue
1809
+ all_matches.append((src_dir.name, line[:300]))
1810
+ sources_hit.add(src_dir.name)
1811
+ if len(all_matches) >= max_matches:
1812
+ break
1813
+ except Exception:
1814
+ pass
1815
+ if len(all_matches) >= max_matches:
1816
+ break
1817
+ total = len(all_matches)
1818
+ if total == 0:
1819
+ return json.dumps({
1820
+ "query": query_f,
1821
+ "total_matches": 0,
1822
+ "sources_searched": [d.name for d in src_dirs],
1823
+ "note": "No matches found in synced logs.",
1824
+ })
1825
+ sig_counter = _Counter()
1826
+ sig_examples = {}
1827
+ for src, line in all_matches:
1828
+ sig = _signature(line)
1829
+ sig_counter[sig] += 1
1830
+ if sig not in sig_examples:
1831
+ sig_examples[sig] = f"[{src}] {line}"
1832
+ top_patterns = [
1833
+ {"pattern": sig, "count": cnt, "example": sig_examples[sig][:250]}
1834
+ for sig, cnt in sig_counter.most_common(10)
1835
+ ]
1836
+ sample_lines = []
1837
+ seen_sigs = set()
1838
+ for src, line in all_matches:
1839
+ sig = _signature(line)
1840
+ if sig not in seen_sigs:
1841
+ sample_lines.append(f"[{src}] {line}")
1842
+ seen_sigs.add(sig)
1843
+ if len(sample_lines) >= 10:
1844
+ break
1845
+ time_span = {}
1846
+ try:
1847
+ from .log_fetcher import _parse_line_ts
1848
+ timestamps = [_parse_line_ts(ln) for _, ln in all_matches]
1849
+ timestamps = [t for t in timestamps if t]
1850
+ if timestamps:
1851
+ time_span = {
1852
+ "earliest": min(timestamps).strftime("%Y-%m-%d %H:%M:%S UTC"),
1853
+ "latest": max(timestamps).strftime("%Y-%m-%d %H:%M:%S UTC"),
1854
+ }
1855
+ except Exception:
1856
+ pass
1857
+ return json.dumps({
1858
+ "query": query_f,
1859
+ "total_matches": total,
1860
+ "sources_hit": sorted(sources_hit),
1861
+ "sources_searched": [d.name for d in src_dirs],
1862
+ "top_patterns": top_patterns,
1863
+ "sample_lines": sample_lines,
1864
+ "time_span": time_span,
1865
+ "capped": total >= max_matches,
1866
+ })
1867
+ if name == "trigger_poll":
1868
+ Path("SENTINEL_POLL_NOW").touch()
1869
+ logger.info("Boss: immediate poll requested")
1870
+ return json.dumps({"status": "triggered", "note": "Sentinel will run a poll cycle within seconds"})
1871
+ if name == "get_repo_status":
1872
+ hours = int(inputs.get("hours", 24))
1873
+ fixes = store.get_recent_fixes(hours)
1874
+ errors = store.get_recent_errors(hours)
1875
+ by_repo: dict = {}
1876
+ for fix in fixes:
1877
+ repo = fix.get("repo_name", "unknown")
1878
+ s = by_repo.setdefault(repo, {"applied": 0, "pending": 0, "failed": 0, "skipped": 0})
1879
+ key = fix.get("status", "failed")
1880
+ s[key] = s.get(key, 0) + 1
1881
+ return json.dumps({"window_hours": hours, "total_errors": len(errors), "by_repo": by_repo})
1882
+ if name == "list_recent_commits":
1883
+ limit = int(inputs.get("limit", 5))
1884
+ results = []
1885
+ for repo_name, repo in cfg_loader.repos.items():
1886
+ local = Path(repo.local_path)
1887
+ if not local.exists():
1888
+ continue
1889
+ try:
1890
+ r = subprocess.run(
1891
+ ["git", "log", "--oneline", "--grep=sentinel", "-n", str(limit)],
1892
+ cwd=str(local), capture_output=True, text=True, timeout=10,
1893
+ )
1894
+ commits = r.stdout.strip().splitlines()
1895
+ if commits:
1896
+ results.append({"repo": repo_name, "commits": commits})
1897
+ except Exception:
1898
+ pass
1899
+ return json.dumps({"sentinel_commits": results})
1900
+ if name == "pull_repo":
1901
+ target = inputs.get("repo", "").lower()
1902
+ results = []
1903
+ for repo_name, repo in cfg_loader.repos.items():
1904
+ if target and target not in repo_name.lower():
1905
+ continue
1906
+ local = Path(repo.local_path)
1907
+ if not local.exists():
1908
+ results.append({"repo": repo_name, "status": "error", "detail": "local path not found"})
1909
+ continue
1910
+ try:
1911
+ r = subprocess.run(
1912
+ ["git", "pull", "--rebase", "origin", repo.branch],
1913
+ cwd=str(local), capture_output=True, text=True, timeout=60,
1914
+ )
1915
+ last_line = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else "already up to date"
1916
+ if r.returncode == 0:
1917
+ results.append({"repo": repo_name, "status": "ok", "detail": last_line})
1918
+ else:
1919
+ results.append({"repo": repo_name, "status": "error", "detail": r.stderr.strip()})
1920
+ except Exception as e:
1921
+ results.append({"repo": repo_name, "status": "error", "detail": str(e)})
1922
+ return json.dumps({"results": results})
1923
+ if name == "pull_config":
1924
+ target = inputs.get("project", "")
1925
+ dirs = _find_project_dirs(target)
1926
+ if not dirs:
1927
+ return json.dumps({"error": f"No project found matching '{target}'"})
1928
+ results = []
1929
+ for d in dirs:
1930
+ res = _git_pull(d)
1931
+ results.append({"project": _read_project_name(d), "dir": d.name, **res})
1932
+ logger.info("Boss: pull_config %s → %s", d.name, res["status"])
1933
+ return json.dumps({"results": results})
1934
+ if name == "fetch_logs":
1935
+ source_filter = inputs.get("source", "").lower()
1936
+ debug = bool(inputs.get("debug", False))
1937
+ tail_override = inputs.get("tail")
1938
+ grep_override = inputs.get("grep_filter", "")
1939
+ script = Path(__file__).resolve().parent.parent / "scripts" / "fetch_log.sh"
1940
+ if not script.exists():
1941
+ return json.dumps({"error": f"fetch_log.sh not found at {script}"})
1942
+ log_cfg_dir = Path("config") / "log-configs"
1943
+ if not log_cfg_dir.exists():
1944
+ return json.dumps({"error": "config/log-configs/ not found"})
1945
+ props_files = _filter_log_sources(sorted(log_cfg_dir.glob("*.properties")), source_filter)
1946
+ if not props_files:
1947
+ return json.dumps({"error": f"No log-config found matching '{source_filter}'"})
1948
+ results = []
1949
+ for props in props_files:
1950
+ env = os.environ.copy()
1951
+ if tail_override:
1952
+ env["TAIL"] = str(tail_override)
1953
+ if grep_override:
1954
+ env["GREP_FILTER"] = grep_override
1955
+ cmd = ["bash", str(script)]
1956
+ if debug:
1957
+ cmd.append("--debug")
1958
+ cmd.append(str(props))
1959
+ try:
1960
+ r = subprocess.run(
1961
+ cmd, capture_output=True, text=True, timeout=120, env=env,
1962
+ )
1963
+ output = (r.stdout or "").strip()
1964
+ stderr = (r.stderr or "").strip()
1965
+ results.append({
1966
+ "source": props.stem,
1967
+ "returncode": r.returncode,
1968
+ "output": output[-2000:] if output else "",
1969
+ "stderr": stderr[-1000:] if stderr else "",
1970
+ })
1971
+ logger.info("Boss fetch_logs %s rc=%d", props.stem, r.returncode)
1972
+ except subprocess.TimeoutExpired:
1973
+ results.append({"source": props.stem, "error": "timed out after 120s"})
1974
+ except Exception as e:
1975
+ results.append({"source": props.stem, "error": str(e)})
1976
+ return json.dumps({"fetched": len(results), "results": results})
1977
+ if name == "watch_bot":
1978
+ if not is_admin:
1979
+ return json.dumps({"error": "watch_bot is admin-only. Ask a Sentinel admin to register this bot."})
1980
+ user_ids = inputs.get("user_ids", [])
1981
+ project_arg = inputs.get("project", "").strip()
1982
+ if not user_ids:
1983
+ return json.dumps({"error": "No user_ids provided"})
1984
+ resolved_project = ""
1985
+ if project_arg:
1986
+ project_dirs = _find_project_dirs(project_arg)
1987
+ if not project_dirs:
1988
+ all_names = [_read_project_name(d) for d in _find_project_dirs()]
1989
+ return json.dumps({
1990
+ "error": f"No project found matching '{project_arg}'",
1991
+ "available_projects": all_names,
1992
+ "action_needed": "Ask the user which project these bot alerts belong to.",
1993
+ })
1994
+ if len(project_dirs) > 1:
1995
+ matches = [_read_project_name(d) for d in project_dirs]
1996
+ return json.dumps({
1997
+ "error": f"Ambiguous project name '{project_arg}' — matches: {matches}",
1998
+ "action_needed": "Ask the user to clarify which project.",
1999
+ })
2000
+ resolved_project = _read_project_name(project_dirs[0])
2001
+ else:
2002
+ all_projects = _find_project_dirs()
2003
+ if len(all_projects) == 1:
2004
+ resolved_project = _read_project_name(all_projects[0])
2005
+ elif all_projects:
2006
+ all_names = [_read_project_name(d) for d in all_projects]
2007
+ return json.dumps({
2008
+ "error": "Cannot determine which project these bot alerts belong to.",
2009
+ "available_projects": all_names,
2010
+ "action_needed": "Ask the user to specify the project, then retry with project filled in.",
2011
+ })
2012
+ results = []
2013
+ for uid in user_ids:
2014
+ if not slack_client:
2015
+ results.append({"user_id": uid, "status": "error", "reason": "no Slack client available"})
2016
+ continue
2017
+ try:
2018
+ info = await slack_client.users_info(user=uid)
2019
+ user = info.get("user", {})
2020
+ if not user.get("is_bot", False):
2021
+ results.append({"user_id": uid, "status": "skipped", "reason": "not a bot — only bots can be watched passively"})
2022
+ continue
2023
+ bot_name = user.get("real_name") or user.get("name") or uid
2024
+ store.add_watched_bot(uid, bot_name, added_by="boss", project_name=resolved_project)
2025
+ logger.info("Boss: now watching bot %s (%s) → project '%s'", bot_name, uid, resolved_project or "unset")
2026
+ results.append({"user_id": uid, "bot_name": bot_name, "project": resolved_project, "status": "watching"})
2027
+ except Exception as e:
2028
+ results.append({"user_id": uid, "status": "error", "reason": str(e)})
2029
+ return json.dumps({"results": results})
2030
+ if name == "unwatch_bot":
2031
+ if not is_admin:
2032
+ return json.dumps({"error": "unwatch_bot is admin-only. Ask a Sentinel admin to remove this bot."})
2033
+ user_ids = inputs.get("user_ids", [])
2034
+ if not user_ids:
2035
+ return json.dumps({"error": "No user_ids provided"})
2036
+ results = []
2037
+ for uid in user_ids:
2038
+ removed = store.remove_watched_bot(uid)
2039
+ logger.info("Boss: unwatch bot %s → %s", uid, "removed" if removed else "not found")
2040
+ results.append({"user_id": uid, "status": "removed" if removed else "not found"})
2041
+ return json.dumps({"results": results})
2042
+ if name == "list_watched_bots":
2043
+ bots = store.get_watched_bots()
2044
+ return json.dumps({
2045
+ "count": len(bots),
2046
+ "bots": [
2047
+ {
2048
+ "bot_id": b["bot_id"],
2049
+ "bot_name": b["bot_name"],
2050
+ "project": b.get("project_name") or "",
2051
+ "added_by": b["added_by"],
2052
+ "added_at": b["added_at"],
2053
+ }
2054
+ for b in bots
2055
+ ],
2056
+ })
2057
+ if name == "upgrade_sentinel":
2058
+ if not is_admin:
2059
+ return json.dumps({"error": "upgrade is admin-only. Ask a Sentinel admin to perform the upgrade."})
2060
+ import threading
2061
+ try:
2062
+ r = subprocess.run(
2063
+ ["sentinel", "--version"],
2064
+ capture_output=True, text=True, timeout=10,
2065
+ )
2066
+ sentinel_bin_ok = r.returncode == 0
2067
+ except Exception:
2068
+ sentinel_bin_ok = False
2069
+ if not sentinel_bin_ok:
2070
+ return json.dumps({
2071
+ "status": "error",
2072
+ "note": "`sentinel` CLI not found. Run: npm install -g @misterhuydo/sentinel",
2073
+ })
2074
+ def _do_upgrade():
2075
+ import time
2076
+ time.sleep(10)
2077
+ subprocess.Popen(["sentinel", "upgrade"], close_fds=True)
2078
+ threading.Thread(target=_do_upgrade, daemon=True).start()
2079
+ logger.info("Boss: upgrade_sentinel scheduled via `sentinel upgrade`")
2080
+ return json.dumps({
2081
+ "status": "ok",
2082
+ "note": "Upgrade started — pulling latest version via npm and restarting. Give me ~30 seconds then I'll be back.",
2083
+ })
2084
+ if name == "ask_codebase":
2085
+ target = inputs.get("repo", "").lower()
2086
+ question = inputs.get("question", "")
2087
+ matched = [(rn, r) for rn, r in cfg_loader.repos.items() if target in rn.lower()]
2088
+ if not matched:
2089
+ current_project = _read_project_name(Path("."))
2090
+ if target in current_project.lower() or current_project.lower() in target:
2091
+ matched = list(cfg_loader.repos.items())
2092
+ if not matched:
2093
+ return json.dumps({
2094
+ "error": f"No repo or project found matching '{target}'",
2095
+ "available_repos": list(cfg_loader.repos.keys()),
2096
+ })
2097
+ mode = inputs.get("mode", "explore")
2098
+ cfg = cfg_loader.sentinel
2099
+ env = os.environ.copy()
2100
+ if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
2101
+ env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
2102
+ api_env = {**env, "ANTHROPIC_API_KEY": cfg.anthropic_api_key} if cfg.anthropic_api_key else None
2103
+ _project_name = _read_project_name(Path("."))
2104
+ _all_repos_lines = []
2105
+ for _rn, _rc in cfg_loader.repos.items():
2106
+ _url = getattr(_rc, "repo_url", "") or ""
2107
+ _path = getattr(_rc, "local_path", "") or ""
2108
+ _prefixes = getattr(_rc, "package_prefixes", "") or ""
2109
+ _all_repos_lines.append(
2110
+ f" - {_rn}: path={_path}"
2111
+ + (f", url={_url}" if _url else "")
2112
+ + (f", packages={_prefixes}" if _prefixes else "")
2113
+ )
2114
+ _project_ctx = (
2115
+ f"Project: {_project_name}\n"
2116
+ f"Repos managed by this Sentinel instance:\n"
2117
+ + "\n".join(_all_repos_lines)
2118
+ )
2119
+ def _ask_one(repo_name, repo_cfg) -> dict:
2120
+ local_path = Path(repo_cfg.local_path)
2121
+ if not local_path.exists():
2122
+ return {"repo": repo_name, "error": f"not cloned yet at {local_path}"}
2123
+ if mode == "issues":
2124
+ _mode_instruction = (
2125
+ "Output a structured list of GitHub issues to raise for this codebase.\n"
2126
+ "For each issue include:\n"
2127
+ " TITLE: <concise issue title>\n"
2128
+ " LABELS: <bug|enhancement|tech-debt|security|performance>\n"
2129
+ " DESCRIPTION: <2-4 sentences: what, why, suggested approach>\n"
2130
+ "---\n"
2131
+ "Focus on: bugs, missing error handling, performance bottlenecks, "
2132
+ "security gaps, missing tests, tech-debt, and useful new features.\n"
2133
+ "Output plain text only — no markdown headers."
2134
+ )
2135
+ else:
2136
+ _mode_instruction = (
2137
+ "Explore the codebase freely — read files, search for patterns, examine structure.\n"
2138
+ "Answer thoroughly. You may discuss architecture, suggest extensions, "
2139
+ "describe design patterns, or analyse any aspect of the code.\n"
2140
+ "Plain text only. Be concise but complete."
2141
+ )
2142
+ prompt = (
2143
+ f"{_project_ctx}\n\n"
2144
+ f"You are now analysing: {repo_name} at {local_path}\n\n"
2145
+ f"{_mode_instruction}\n\n"
2146
+ f"Question / Task: {question}"
2147
+ )
2148
+ from .fix_engine import _is_auth_error
2149
+ skip_perms = os.getuid() != 0
2150
+ oauth_cmd = (
2151
+ [cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt]
2152
+ if skip_perms else
2153
+ [cfg.claude_code_bin, "--print", prompt]
2154
+ )
2155
+ api_cmd = (
2156
+ [cfg.claude_code_bin, "--dangerously-skip-permissions", "--bare", "--print", prompt]
2157
+ if skip_perms else
2158
+ [cfg.claude_code_bin, "--bare", "--print", prompt]
2159
+ )
2160
+ run_kwargs = dict(capture_output=True, text=True, timeout=300,
2161
+ cwd=str(local_path), stdin=subprocess.DEVNULL)
2162
+ try:
2163
+ r = subprocess.run(oauth_cmd, env=env, **run_kwargs)
2164
+ output = (r.stdout or "").strip()
2165
+ auth_failed = _is_auth_error(output) or _is_auth_error(r.stderr or "")
2166
+ if auth_failed and api_env:
2167
+ logger.warning("ask_codebase/%s: OAuth session issue — retrying with API key", repo_name)
2168
+ r = subprocess.run(api_cmd, env=api_env, **run_kwargs)
2169
+ output = (r.stdout or "").strip()
2170
+ logger.info("Boss ask_codebase %s mode=%s rc=%d len=%d", repo_name, mode, r.returncode, len(output))
2171
+ if r.returncode != 0 and not output:
2172
+ raw_err = (r.stderr or "")
2173
+ alert_if_rate_limited(
2174
+ cfg.slack_bot_token, cfg.slack_channel,
2175
+ f"ask_codebase/{repo_name}", raw_err,
2176
+ )
2177
+ return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {raw_err[:200]}"}
2178
+ return {"repo": repo_name, "answer": output[:4000]}
2179
+ except subprocess.TimeoutExpired:
2180
+ return {"repo": repo_name, "error": "timed out after 300s"}
2181
+ except Exception as e:
2182
+ return {"repo": repo_name, "error": str(e)}
2183
+ def _ask_cached(repo_name, repo_cfg) -> dict:
2184
+ if mode != "issues":
2185
+ cached = store.get_knowledge(repo_name, question)
2186
+ if cached:
2187
+ logger.info("Boss ask_codebase %s: cache hit", repo_name)
2188
+ return {"repo": repo_name, "answer": cached, "cached": True}
2189
+ result = _ask_one(repo_name, repo_cfg)
2190
+ if result.get("answer") and len(result["answer"]) > 50 and mode != "issues":
2191
+ store.save_knowledge(repo_name, question, result["answer"], ttl_hours=24)
2192
+ return result
2193
+ if len(matched) == 1:
2194
+ result = _ask_cached(*matched[0])
2195
+ return json.dumps(result)
2196
+ results = [_ask_cached(rn, r) for rn, r in matched]
2197
+ return json.dumps({"project": target, "repos_queried": len(results), "results": results})
2198
+ if name == "ask_logs":
2199
+ question = inputs.get("question", "")
2200
+ source_arg = inputs.get("source", "").lower()
2201
+ cfg = cfg_loader.sentinel
2202
+ workspace = Path(cfg.workspace_dir)
2203
+ synced_base = workspace / "synced"
2204
+ fetched_base = workspace / "fetched"
2205
+ log_files = []
2206
+ if source_arg:
2207
+ if synced_base.exists():
2208
+ for d in sorted(synced_base.iterdir()):
2209
+ if d.is_dir() and source_arg in d.name.lower():
2210
+ log_files.extend(sorted(d.glob("*")))
2211
+ for f in sorted(fetched_base.glob("*.log")):
2212
+ if source_arg in f.stem.lower() and f not in log_files:
2213
+ log_files.append(f)
2214
+ else:
2215
+ if synced_base.exists():
2216
+ for d in sorted(synced_base.iterdir()):
2217
+ if d.is_dir():
2218
+ log_files.extend(sorted(d.glob("*")))
2219
+ for f in sorted(fetched_base.glob("*.log")):
2220
+ if f not in log_files:
2221
+ log_files.append(f)
2222
+ if not log_files:
2223
+ hint = (
2224
+ f"No log files found for source '{source_arg}'."
2225
+ if source_arg else "No log files found."
2226
+ )
2227
+ available = (
2228
+ [d.name for d in synced_base.iterdir() if d.is_dir()]
2229
+ if synced_base.exists() else []
2230
+ )
2231
+ return json.dumps({
2232
+ "error": hint,
2233
+ "available_sources": available,
2234
+ "hint": "Run fetch_logs first, or wait for the next poll cycle.",
2235
+ })
2236
+ file_list = "\n".join(f" {p}" for p in log_files)
2237
+ prompt = (
2238
+ f"You are analyzing production logs.\n\n"
2239
+ f"QUESTION: {question}\n\n"
2240
+ f"LOG FILES (use your Read and Grep tools to search these):\n{file_list}\n\n"
2241
+ f"Search the log files and answer the question. "
2242
+ f"Be concise and direct. Plain text only — no markdown."
2243
+ )
2244
+ env = os.environ.copy()
2245
+ if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
2246
+ env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
2247
+ try:
2248
+ skip_flag = []
2249
+ try:
2250
+ if os.getuid() != 0:
2251
+ skip_flag = ["--dangerously-skip-permissions"]
2252
+ except AttributeError:
2253
+ skip_flag = ["--dangerously-skip-permissions"]
2254
+ r = subprocess.run(
2255
+ [cfg.claude_code_bin] + skip_flag + ["--print", prompt],
2256
+ capture_output=True, text=True, timeout=240, env=env,
2257
+ cwd=str(workspace),
2258
+ )
2259
+ output = (r.stdout or "").strip()
2260
+ logger.info("Boss ask_logs source=%s rc=%d len=%d", source_arg or "all", r.returncode, len(output))
2261
+ if r.returncode != 0 and not output:
2262
+ raw_err = (r.stderr or "")
2263
+ alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
2264
+ f"ask_logs/{source_arg or 'all'}", raw_err)
2265
+ return json.dumps({"error": f"claude --print failed (rc={r.returncode}): {raw_err[:300]}"})
2266
+ return json.dumps({
2267
+ "source": source_arg or "all",
2268
+ "files_searched": len(log_files),
2269
+ "answer": output[:4000],
2270
+ })
2271
+ except subprocess.TimeoutExpired:
2272
+ return json.dumps({"error": "timed out after 240s"})
2273
+ except Exception as e:
2274
+ return json.dumps({"error": str(e)})
2275
+ if name == "restart_project":
2276
+ if not is_admin:
2277
+ return json.dumps({"error": "restart_project is admin-only. Ask a Sentinel admin to restart the project."})
2278
+ project_arg = inputs.get("project", "").lower()
2279
+ dirs = _find_project_dirs(project_arg)
2280
+ if not dirs:
2281
+ return json.dumps({"error": f"No project found matching '{project_arg}'"})
2282
+ results = []
2283
+ for d in dirs:
2284
+ stop_sh = d / "stop.sh"
2285
+ start_sh = d / "start.sh"
2286
+ if not stop_sh.exists() or not start_sh.exists():
2287
+ results.append({"project": d.name, "status": "error", "detail": "stop.sh or start.sh not found"})
2288
+ continue
2289
+ try:
2290
+ subprocess.run(["bash", str(stop_sh)], cwd=str(d), timeout=30)
2291
+ subprocess.run(["bash", str(start_sh)], cwd=str(d), timeout=30)
2292
+ results.append({"project": d.name, "status": "restarted"})
2293
+ logger.info("Boss: restarted project %s", d.name)
2294
+ except Exception as e:
2295
+ results.append({"project": d.name, "status": "error", "detail": str(e)})
2296
+ return json.dumps({"results": results})
2297
+ if name == "tail_log":
2298
+ source = inputs.get("source", "").lower()
2299
+ lines = int(inputs.get("lines", 100))
2300
+ script = Path(__file__).resolve().parent.parent / "scripts" / "fetch_log.sh"
2301
+ log_cfg_dir = Path("config") / "log-configs"
2302
+ if not script.exists():
2303
+ return json.dumps({"error": "fetch_log.sh not found"})
2304
+ if not log_cfg_dir.exists():
2305
+ return json.dumps({"error": "config/log-configs/ not found"})
2306
+ props_files = sorted(log_cfg_dir.glob("*.properties"))
2307
+ if source:
2308
+ props_files = [p for p in props_files if source in p.stem.lower()]
2309
+ if not props_files:
2310
+ return json.dumps({"error": f"No log-config found matching '{source}'"})
2311
+ results = []
2312
+ for props in props_files:
2313
+ env = os.environ.copy()
2314
+ env["TAIL"] = str(lines)
2315
+ env["GREP_FILTER"] = ""
2316
+ try:
2317
+ r = subprocess.run(
2318
+ ["bash", str(script), str(props)],
2319
+ capture_output=True, text=True, timeout=60, env=env,
2320
+ )
2321
+ tail_lines = (r.stdout or "").strip().splitlines()[-lines:]
2322
+ results.append({
2323
+ "source": props.stem,
2324
+ "lines": len(tail_lines),
2325
+ "content": "\n".join(tail_lines),
2326
+ })
2327
+ logger.info("Boss tail_log %s rc=%d lines=%d", props.stem, r.returncode, len(tail_lines))
2328
+ except subprocess.TimeoutExpired:
2329
+ results.append({"source": props.stem, "error": "timed out"})
2330
+ except Exception as e:
2331
+ results.append({"source": props.stem, "error": str(e)})
2332
+ return json.dumps({"results": results})
2333
+ if name == "post_file":
2334
+ if not slack_client or not channel:
2335
+ return json.dumps({"error": "No Slack channel context — cannot upload file"})
2336
+ content = inputs.get("content", "")
2337
+ filename = inputs.get("filename", "sentinel-output.txt")
2338
+ title = inputs.get("title", filename)
2339
+ if not content:
2340
+ return json.dumps({"error": "No content provided"})
2341
+ try:
2342
+ await slack_client.files_upload_v2(
2343
+ channel=channel,
2344
+ content=content,
2345
+ filename=filename,
2346
+ title=title,
2347
+ )
2348
+ logger.info("Boss post_file: uploaded %s (%d bytes) to %s", filename, len(content), channel)
2349
+ return json.dumps({"ok": True, "filename": filename, "bytes": len(content)})
2350
+ except Exception as e:
2351
+ logger.warning("Boss post_file failed: %s", e)
2352
+ return json.dumps({"error": str(e)})
2353
+ if name == "my_stats":
2354
+ hours = int(inputs.get("hours", 168))
2355
+ errors = store.get_recent_errors(hours)
2356
+ fixes = store.get_recent_fixes(hours)
2357
+ prs = store.get_open_prs()
2358
+ pending_conf = store.get_fixes_pending_confirmation()
2359
+ history = store.load_conversation(user_id) if user_id else []
2360
+ hist_len = len(history)
2361
+ conv_updated = ""
2362
+ try:
2363
+ import sqlite3 as _sqlite3
2364
+ with _sqlite3.connect(store.db_path) as _db:
2365
+ row = _db.execute(
2366
+ "SELECT updated_at FROM conversations WHERE user_id=?", (user_id,)
2367
+ ).fetchone()
2368
+ if row:
2369
+ conv_updated = row[0]
2370
+ except Exception:
2371
+ pass
2372
+ by_status: dict = {}
2373
+ for fix in fixes:
2374
+ s = fix.get("status", "unknown")
2375
+ by_status[s] = by_status.get(s, 0) + 1
2376
+ confirmed = [f for f in fixes if f.get("fix_outcome") == "confirmed"]
2377
+ regressed = [f for f in fixes if f.get("fix_outcome") == "regressed"]
2378
+ submitted = store.get_submitted_issues(user_id, hours=hours) if user_id else []
2379
+ submitted_recent = store.get_submitted_issues(user_id, hours=hours) if user_id else []
2380
+ return json.dumps({
2381
+ "conversation": {
2382
+ "messages_in_history": hist_len,
2383
+ "turns": hist_len // 2,
2384
+ "last_active": conv_updated or "no history",
2385
+ },
2386
+ "issues_you_submitted": {
2387
+ "total_in_window": len(submitted_recent),
2388
+ "all_time": len(store.get_submitted_issues(user_id) if user_id else []),
2389
+ "recent": [
2390
+ {"project": i["project"], "description": i["description"][:80],
2391
+ "submitted_at": i["submitted_at"]}
2392
+ for i in submitted_recent[:5]
2393
+ ],
2394
+ },
2395
+ "window_hours": hours,
2396
+ "errors_detected": len(errors),
2397
+ "fixes": {
2398
+ "applied": by_status.get("applied", 0),
2399
+ "pending_pr": len(prs),
2400
+ "failed": by_status.get("failed", 0),
2401
+ "skipped": by_status.get("skipped", 0),
2402
+ "error": by_status.get("error", 0),
2403
+ },
2404
+ "confirmed_in_prod": len(confirmed),
2405
+ "regressed_after_fix": len(regressed),
2406
+ "awaiting_confirmation": len(pending_conf),
2407
+ "open_prs": [
2408
+ {"repo": p["repo_name"], "pr_url": p["pr_url"], "timestamp": p["timestamp"]}
2409
+ for p in prs
2410
+ ],
2411
+ "top_errors": [
2412
+ {"message": e["message"][:100], "count": e["count"], "source": e["source"]}
2413
+ for e in errors[:5]
2414
+ ],
2415
+ })
2416
+ if name == "clear_my_history":
2417
+ if user_id:
2418
+ store.save_conversation(user_id, [])
2419
+ logger.info("Boss: cleared conversation history for user %s", user_id)
2420
+ return json.dumps({
2421
+ "status": "cleared",
2422
+ "note": "Your conversation history has been wiped. Next session starts fresh. [DONE]",
2423
+ })
2424
+ return json.dumps({"error": "cannot determine user — not clearing"})
2425
+ _ADMIN_TOOLS = {"list_all_users", "clear_user_history", "reset_fingerprint", "list_all_errors", "export_db", "merge_pr", "install_tool", "manage_release", "chain_release"}
2426
+ if name in _ADMIN_TOOLS:
2427
+ if not is_admin:
2428
+ return json.dumps({"error": "This operation is admin-only (SLACK_ADMIN_USERS). Contact a Sentinel admin if you need access."})
2429
+ if name == "list_all_users":
2430
+ stats = store.get_all_user_stats()
2431
+ return json.dumps({"users": stats, "total": len(stats)})
2432
+ if name == "clear_user_history":
2433
+ target = (inputs.get("user_id") or inputs.get("target_user_id", "")).strip()
2434
+ if not target:
2435
+ return json.dumps({"error": "user_id is required"})
2436
+ store.save_conversation(target, [])
2437
+ display = store.get_user_name(target)
2438
+ logger.info("Boss admin: cleared history for user %s (%s) by admin %s", target, display, user_id)
2439
+ return json.dumps({"status": "cleared", "target_user_id": target, "display_name": display})
2440
+ if name == "set_maintenance":
2441
+ repo_name = inputs.get("repo_name", "").strip()
2442
+ note = inputs.get("note", "").strip()
2443
+ if not repo_name:
2444
+ return json.dumps({"error": "repo_name is required"})
2445
+ store.set_health_state(repo_name, "confirmed", note=note)
2446
+ logger.info("Boss: maintenance confirmed for %s by %s (note: %s)", repo_name, user_id, note or "none")
2447
+ return json.dumps({
2448
+ "status": "confirmed",
2449
+ "repo": repo_name,
2450
+ "note": note or "none",
2451
+ "message": (
2452
+ f"Got it. I'll silently monitor {repo_name}'s health URL and "
2453
+ f"notify you as soon as it comes back online."
2454
+ ),
2455
+ })
2456
+ if name == "reset_fingerprint":
2457
+ fp = inputs.get("fingerprint", "").strip()
2458
+ if not fp:
2459
+ return json.dumps({"error": "fingerprint is required"})
2460
+ found = store.reset_fingerprint(fp)
2461
+ logger.info("Boss admin: reset fingerprint %s by admin %s (found=%s)", fp, user_id, found)
2462
+ return json.dumps({"status": "reset" if found else "not_found", "fingerprint": fp,
2463
+ "note": "Sentinel will retry this error on the next poll." if found else "No fix record found for this fingerprint."})
2464
+ if name == "list_all_errors":
2465
+ hours = int(inputs.get("hours", 0))
2466
+ errors = store.get_all_errors(hours)
2467
+ return json.dumps({"errors": errors[:100], "total": len(errors),
2468
+ "window_hours": hours or "all time"})
2469
+ if name == "export_db":
2470
+ if not slack_client or not channel:
2471
+ return json.dumps({"error": "No Slack channel context — cannot upload file"})
2472
+ try:
2473
+ import sqlite3 as _sq
2474
+ import io as _io
2475
+ lines = []
2476
+ with _sq.connect(store.db_path) as _db:
2477
+ for tbl in ["errors", "fixes", "reports", "slack_users", "conversations", "submitted_issues"]:
2478
+ try:
2479
+ rows = _db.execute(f"SELECT * FROM {tbl}").fetchall()
2480
+ cols = [d[0] for d in _db.execute(f"SELECT * FROM {tbl} LIMIT 0").description]
2481
+ lines.append(f"=== {tbl} ({len(rows)} rows) ===")
2482
+ lines.append("\t".join(cols))
2483
+ for row in rows:
2484
+ lines.append("\t".join(str(v) if v is not None else "" for v in row))
2485
+ lines.append("")
2486
+ except Exception:
2487
+ lines.append(f"=== {tbl} (unavailable) ===\n")
2488
+ content = "\n".join(lines)
2489
+ await slack_client.files_upload_v2(
2490
+ channel=channel,
2491
+ content=content,
2492
+ filename="sentinel-db-export.tsv",
2493
+ title="Sentinel DB Export",
2494
+ )
2495
+ logger.info("Boss admin: exported DB (%d bytes) by admin %s", len(content), user_id)
2496
+ return json.dumps({"ok": True, "bytes": len(content)})
2497
+ except Exception as e:
2498
+ return json.dumps({"error": str(e)})
2499
+ if name == "refresh_knowledge":
2500
+ repo_filter = inputs.get("repo", "").strip()
2501
+ list_only = bool(inputs.get("list_only", False))
2502
+ if list_only:
2503
+ entries = store.list_knowledge(repo_name=repo_filter)
2504
+ if not entries:
2505
+ return json.dumps({"message": "Knowledge cache is empty.", "entries": []})
2506
+ return json.dumps({
2507
+ "total": len(entries),
2508
+ "entries": [
2509
+ {
2510
+ "repo": e["repo_name"],
2511
+ "question": e["question"][:80],
2512
+ "cached_at": e["cached_at"][:16],
2513
+ "expires_at": e["expires_at"][:16],
2514
+ "hits": e["hit_count"],
2515
+ "tool": e["source_tool"],
2516
+ }
2517
+ for e in entries
2518
+ ],
2519
+ })
2520
+ deleted = store.invalidate_knowledge(repo_name=repo_filter)
2521
+ scope = f"repo '{repo_filter}'" if repo_filter else "all repos"
2522
+ logger.info("Boss refresh_knowledge: cleared %d entries for %s by %s", deleted, scope, user_id)
2523
+ return json.dumps({
2524
+ "status": "cleared",
2525
+ "deleted": deleted,
2526
+ "scope": scope,
2527
+ "note": "Next ask_codebase call will fetch a fresh answer from Claude.",
2528
+ })
2529
+ if name == "list_prs":
2530
+ if not is_admin:
2531
+ return json.dumps({"error": "list_prs is admin-only. Ask a Sentinel admin (SLACK_ADMIN_USERS) to run this for you."})
2532
+ repo_filter = inputs.get("repo", "").strip()
2533
+ status_filter = inputs.get("status", "").strip()
2534
+ if status_filter == "pending":
2535
+ prs = store.get_prs(repo_name=repo_filter, state="open", decision="pending")
2536
+ elif status_filter:
2537
+ prs = store.get_prs(repo_name=repo_filter, state=status_filter)
2538
+ else:
2539
+ prs = store.get_prs(repo_name=repo_filter)
2540
+ if not prs:
2541
+ return json.dumps({"message": "No PRs found matching the filter.", "prs": []})
2542
+ formatted = []
2543
+ for p in prs:
2544
+ entry = {
2545
+ "repo": p["repo_name"],
2546
+ "pr_number": p["pr_number"],
2547
+ "title": p["title"],
2548
+ "author": p["author"],
2549
+ "source": p["source"],
2550
+ "state": p["pr_state"],
2551
+ "url": p["pr_url"],
2552
+ "first_seen": p["first_seen"][:10] if p["first_seen"] else "",
2553
+ }
2554
+ if p.get("admin_decision"):
2555
+ entry["decision"] = p["admin_decision"]
2556
+ entry["decided_by"] = p.get("admin_user_id", "")
2557
+ entry["decided_at"] = (p.get("admin_decided_at") or "")[:10]
2558
+ else:
2559
+ entry["decision"] = "pending"
2560
+ formatted.append(entry)
2561
+ return json.dumps({"total": len(formatted), "prs": formatted})
2562
+ if name == "drop_pr":
2563
+ if not is_admin:
2564
+ return json.dumps({"error": "drop_pr is admin-only. Ask a Sentinel admin to drop this PR for you."})
2565
+ repo_name = inputs.get("repo_name", "").strip()
2566
+ pr_number = inputs.get("pr_number")
2567
+ if not repo_name or not pr_number:
2568
+ return json.dumps({"error": "repo_name and pr_number are required"})
2569
+ pr_number = int(pr_number)
2570
+ if repo_name not in cfg_loader.repos:
2571
+ for rname in cfg_loader.repos:
2572
+ if repo_name.lower() in rname.lower():
2573
+ repo_name = rname
2574
+ break
2575
+ store.record_pr_decision(repo_name, pr_number, "rejected", user_id)
2576
+ logger.info("Boss drop_pr: PR
2577
+ return json.dumps({
2578
+ "status": "dropped",
2579
+ "repo": repo_name,
2580
+ "pr_number": pr_number,
2581
+ "dropped_by": user_id,
2582
+ "note": f"PR
2583
+ })
2584
+ if name == "merge_pr":
2585
+ import re as _re
2586
+ import requests as _req
2587
+ repo_name = inputs.get("repo_name", "").strip()
2588
+ fingerprint = inputs.get("fingerprint", "").strip()
2589
+ pr_number_in = inputs.get("pr_number")
2590
+ confirmed = bool(inputs.get("confirmed", False))
2591
+ github_token = cfg_loader.sentinel.github_token
2592
+ if not github_token:
2593
+ return json.dumps({"error": "GITHUB_TOKEN not configured"})
2594
+ headers = {
2595
+ "Authorization": f"Bearer {github_token}",
2596
+ "Accept": "application/vnd.github+json",
2597
+ }
2598
+ if repo_name not in cfg_loader.repos:
2599
+ for rname in cfg_loader.repos:
2600
+ if repo_name.lower() in rname.lower():
2601
+ repo_name = rname
2602
+ break
2603
+ branch_name = inputs.get("branch_name", "").strip()
2604
+ if branch_name and not pr_number_in:
2605
+ repo_cfg = cfg_loader.repos.get(repo_name)
2606
+ owner_repo = ""
2607
+ if repo_cfg and repo_cfg.repo_url:
2608
+ url = repo_cfg.repo_url
2609
+ owner_repo = url.split(":")[-1].removesuffix(".git") if url.startswith("git@") \
2610
+ else "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
2611
+ if not owner_repo:
2612
+ return json.dumps({"error": f"Cannot determine GitHub owner/repo for '{repo_name}'"})
2613
+ base_branch = (repo_cfg.branch if repo_cfg else None) or "main"
2614
+ branch_resp = _req.get(
2615
+ f"https://api.github.com/repos/{owner_repo}/branches/{branch_name}",
2616
+ headers=headers, timeout=15,
2617
+ )
2618
+ if branch_resp.status_code == 404:
2619
+ return json.dumps({"error": f"Branch '{branch_name}' not found in {owner_repo}"})
2620
+ if branch_resp.status_code != 200:
2621
+ return json.dumps({"error": f"GitHub API error ({branch_resp.status_code}): {branch_resp.text[:200]}"})
2622
+ branch_data = branch_resp.json()
2623
+ head_sha = branch_data.get("commit", {}).get("sha", "")[:8]
2624
+ head_msg = branch_data.get("commit", {}).get("commit", {}).get("message", "").splitlines()[0]
2625
+ compare_resp = _req.get(
2626
+ f"https://api.github.com/repos/{owner_repo}/compare/{base_branch}...{branch_name}",
2627
+ headers=headers, timeout=15,
2628
+ )
2629
+ compare_data = compare_resp.json() if compare_resp.status_code == 200 else {}
2630
+ ahead_by = compare_data.get("ahead_by", "?")
2631
+ behind_by = compare_data.get("behind_by", "?")
2632
+ files_list = [f.get("filename", "") for f in compare_data.get("files", [])]
2633
+ if not confirmed:
2634
+ return json.dumps({
2635
+ "plan": f"Merge branch '{branch_name}' into {repo_name}/{base_branch}",
2636
+ "branch": branch_name,
2637
+ "base": base_branch,
2638
+ "head_sha": head_sha,
2639
+ "head_commit": head_msg,
2640
+ "ahead_by": ahead_by,
2641
+ "behind_by": behind_by,
2642
+ "files": files_list,
2643
+ "confirm_prompt": (
2644
+ f"This will merge branch '{branch_name}' (ahead by {ahead_by} commit(s)) "
2645
+ f"into {repo_name}/{base_branch} via GitHub Merge API. "
2646
+ f"Reply with confirmed=true to proceed."
2647
+ ),
2648
+ })
2649
+ merge_resp = _req.post(
2650
+ f"https://api.github.com/repos/{owner_repo}/merges",
2651
+ json={"base": base_branch, "head": branch_name,
2652
+ "commit_message": f"chore: merge branch '{branch_name}' into {base_branch}"},
2653
+ headers=headers, timeout=30,
2654
+ )
2655
+ if merge_resp.status_code == 201:
2656
+ sha = merge_resp.json().get("sha", "")[:8]
2657
+ logger.info("Boss merge_pr (branch): merged '%s' into %s/%s by %s sha=%s",
2658
+ branch_name, repo_name, base_branch, user_id, sha)
2659
+ return json.dumps({
2660
+ "status": "merged",
2661
+ "branch": branch_name,
2662
+ "base": base_branch,
2663
+ "sha": sha,
2664
+ "repo": repo_name,
2665
+ "note": f"Branch '{branch_name}' merged into {base_branch}. SHA: {sha}",
2666
+ })
2667
+ if merge_resp.status_code == 204:
2668
+ return json.dumps({
2669
+ "status": "already_merged",
2670
+ "note": f"Branch '{branch_name}' is already fully merged into {base_branch}.",
2671
+ })
2672
+ if merge_resp.status_code == 409:
2673
+ return json.dumps({
2674
+ "status": "conflict",
2675
+ "error": f"Merge conflict between '{branch_name}' and '{base_branch}'. Resolve manually on GitHub.",
2676
+ })
2677
+ return json.dumps({"status": "error",
2678
+ "error": f"GitHub API returned {merge_resp.status_code}: {merge_resp.text[:300]}"})
2679
+ if pr_number_in:
2680
+ pr_number_in = int(pr_number_in)
2681
+ repo_cfg = cfg_loader.repos.get(repo_name)
2682
+ owner_repo = ""
2683
+ if repo_cfg and repo_cfg.repo_url:
2684
+ url = repo_cfg.repo_url
2685
+ if url.startswith("git@"):
2686
+ owner_repo = url.split(":")[-1].removesuffix(".git")
2687
+ else:
2688
+ owner_repo = "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
2689
+ if not owner_repo:
2690
+ for p in store.get_open_prs():
2691
+ if p.get("repo_name") == repo_name and p.get("pr_url"):
2692
+ m2 = _re.search(r"github\.com/([^/]+/[^/]+)/pull/", p["pr_url"])
2693
+ if m2:
2694
+ owner_repo = m2.group(1)
2695
+ break
2696
+ if not owner_repo:
2697
+ return json.dumps({"error": f"Cannot determine GitHub owner/repo for '{repo_name}'"})
2698
+ pr_resp = _req.get(
2699
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number_in}",
2700
+ headers=headers, timeout=15,
2701
+ )
2702
+ if pr_resp.status_code == 404:
2703
+ return json.dumps({"error": f"PR
2704
+ if pr_resp.status_code in (401, 403):
2705
+ return json.dumps({"error": _GITHUB_TOKEN_403_GUIDE})
2706
+ if pr_resp.status_code != 200:
2707
+ return json.dumps({"error": f"GitHub API error ({pr_resp.status_code}): {pr_resp.text[:200]}"})
2708
+ pr_data = pr_resp.json()
2709
+ if pr_data.get("state") != "open":
2710
+ return json.dumps({"error": f"PR
2711
+ pr_url = pr_data.get("html_url", "")
2712
+ branch = pr_data.get("head", {}).get("ref", "")
2713
+ pr_title = pr_data.get("title", "")
2714
+ pr_author = pr_data.get("user", {}).get("login", "unknown")
2715
+ pr_body = (pr_data.get("body") or "")[:300]
2716
+ files_changed = pr_data.get("changed_files", "?")
2717
+ additions = pr_data.get("additions", "?")
2718
+ deletions = pr_data.get("deletions", "?")
2719
+ mergeable = pr_data.get("mergeable")
2720
+ if not confirmed:
2721
+ return json.dumps({
2722
+ "plan": f"Merge PR
2723
+ "pr_number": pr_number_in,
2724
+ "pr_url": pr_url,
2725
+ "title": pr_title,
2726
+ "author": pr_author,
2727
+ "branch": branch,
2728
+ "files_changed": files_changed,
2729
+ "additions": additions,
2730
+ "deletions": deletions,
2731
+ "mergeable": mergeable,
2732
+ "description": pr_body or "(no description)",
2733
+ "confirm_prompt": (
2734
+ f"This will squash-merge PR
2735
+ f"from {pr_author} into {repo_name}. Reply with confirmed=true to proceed."
2736
+ ),
2737
+ })
2738
+ merge_resp = _req.put(
2739
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number_in}/merge",
2740
+ json={"merge_method": "squash", "commit_title": f"chore: merge PR
2741
+ headers=headers, timeout=30,
2742
+ )
2743
+ if merge_resp.status_code == 200:
2744
+ sha = merge_resp.json().get("sha", "")[:8]
2745
+ store.record_pr_decision(repo_name, pr_number_in, "merged", user_id)
2746
+ logger.info("Boss merge_pr: merged PR
2747
+ return json.dumps({
2748
+ "status": "merged",
2749
+ "pr": pr_url,
2750
+ "pr_number": pr_number_in,
2751
+ "title": pr_title,
2752
+ "sha": sha,
2753
+ "repo": repo_name,
2754
+ "note": f"PR
2755
+ })
2756
+ if merge_resp.status_code in (405, 409):
2757
+ return json.dumps({
2758
+ "status": "conflict",
2759
+ "pr": pr_url,
2760
+ "error": f"PR
2761
+ })
2762
+ return json.dumps({"status": "error", "pr": pr_url,
2763
+ "error": f"GitHub API returned {merge_resp.status_code}: {merge_resp.text[:300]}"})
2764
+ open_prs = store.get_open_prs()
2765
+ candidates = [p for p in open_prs if p.get("repo_name") == repo_name]
2766
+ if fingerprint:
2767
+ candidates = [p for p in candidates if p.get("fingerprint", "").startswith(fingerprint)]
2768
+ if not candidates:
2769
+ return json.dumps({"error": f"No open Sentinel PR found for repo '{repo_name}'"
2770
+ + (f" with fingerprint '{fingerprint}'" if fingerprint else "")})
2771
+ fix = candidates[0]
2772
+ pr_url = fix.get("pr_url", "")
2773
+ branch = fix.get("branch", "")
2774
+ fp = fix.get("fingerprint", "")
2775
+ m = _re.search(r"github\.com/([^/]+/[^/]+)/pull/(\d+)", pr_url)
2776
+ if not m:
2777
+ return json.dumps({"error": f"Cannot parse PR URL: {pr_url}"})
2778
+ owner_repo = m.group(1)
2779
+ pr_number = m.group(2)
2780
+ pr_resp2 = _req.get(
2781
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number}",
2782
+ headers=headers, timeout=15,
2783
+ )
2784
+ pr_detail = pr_resp2.json() if pr_resp2.status_code == 200 else {}
2785
+ pr_title2 = pr_detail.get("title", fix.get("fingerprint", ""))
2786
+ pr_state2 = pr_detail.get("state", "unknown")
2787
+ files_changed2 = pr_detail.get("changed_files", "?")
2788
+ additions2 = pr_detail.get("additions", "?")
2789
+ deletions2 = pr_detail.get("deletions", "?")
2790
+ pr_body2 = (pr_detail.get("body") or "")[:300]
2791
+ if pr_state2 != "open" and pr_state2 != "unknown":
2792
+ return json.dumps({"error": f"PR
2793
+ if not confirmed:
2794
+ return json.dumps({
2795
+ "plan": f"Merge Sentinel fix PR
2796
+ "pr_number": pr_number,
2797
+ "pr_url": pr_url,
2798
+ "title": pr_title2,
2799
+ "fingerprint": fp[:8],
2800
+ "branch": branch,
2801
+ "files_changed": files_changed2,
2802
+ "additions": additions2,
2803
+ "deletions": deletions2,
2804
+ "description": pr_body2 or "(no description)",
2805
+ "confirm_prompt": (
2806
+ f"This will squash-merge Sentinel fix PR
2807
+ f"into {repo_name}/{fix.get('branch', 'main')}. "
2808
+ f"Reply with confirmed=true to proceed."
2809
+ ),
2810
+ })
2811
+ merge_resp = _req.put(
2812
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number}/merge",
2813
+ json={"merge_method": "squash", "commit_title": f"fix(sentinel): merge PR
2814
+ headers=headers, timeout=30,
2815
+ )
2816
+ if merge_resp.status_code == 200:
2817
+ sha = merge_resp.json().get("sha", "")[:8]
2818
+ store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
2819
+ repo_name=repo_name, commit_hash=sha)
2820
+ try:
2821
+ store.record_pr_decision(repo_name, int(pr_number), "merged", user_id)
2822
+ except Exception:
2823
+ pass
2824
+ try:
2825
+ store.invalidate_knowledge(repo_name)
2826
+ except Exception:
2827
+ pass
2828
+ logger.info("Boss merge_pr: merged PR
2829
+ pr_number, repo_name, fp[:8], user_id)
2830
+ return json.dumps({
2831
+ "status": "merged",
2832
+ "pr": pr_url,
2833
+ "sha": sha,
2834
+ "repo": repo_name,
2835
+ "note": f"PR
2836
+ })
2837
+ if merge_resp.status_code in (405, 409):
2838
+ repo_cfg = cfg_loader.repos.get(repo_name)
2839
+ if not repo_cfg or not repo_cfg.local_path:
2840
+ return json.dumps({"error": f"Merge conflict and no local clone found for '{repo_name}'. Resolve manually: {pr_url}"})
2841
+ import subprocess as _sp
2842
+ from .git_manager import _git_env
2843
+ env = _git_env(repo_cfg)
2844
+ cwd = repo_cfg.local_path
2845
+ base = repo_cfg.branch
2846
+ _sp.run(["git", "fetch", "origin"], cwd=cwd, env=env, capture_output=True, timeout=60)
2847
+ _sp.run(["git", "checkout", branch], cwd=cwd, env=env, capture_output=True, timeout=30)
2848
+ rb = _sp.run(["git", "rebase", f"origin/{base}"], cwd=cwd, env=env, capture_output=True, timeout=60)
2849
+ if rb.returncode != 0:
2850
+ _sp.run(["git", "rebase", "--abort"], cwd=cwd, env=env, capture_output=True, timeout=30)
2851
+ _sp.run(["git", "checkout", base], cwd=cwd, env=env, capture_output=True, timeout=30)
2852
+ return json.dumps({
2853
+ "status": "conflict",
2854
+ "error": "Rebase failed — conflicts must be resolved manually",
2855
+ "pr": pr_url,
2856
+ "details": rb.stderr.strip()[:500],
2857
+ })
2858
+ _sp.run(["git", "push", "--force-with-lease", "origin", branch],
2859
+ cwd=cwd, env=env, capture_output=True, timeout=60)
2860
+ _sp.run(["git", "checkout", base], cwd=cwd, env=env, capture_output=True, timeout=30)
2861
+ retry_resp = _req.put(
2862
+ f"https://api.github.com/repos/{owner_repo}/pulls/{pr_number}/merge",
2863
+ json={"merge_method": "squash", "commit_title": f"fix(sentinel): merge PR
2864
+ headers=headers, timeout=30,
2865
+ )
2866
+ if retry_resp.status_code == 200:
2867
+ sha = retry_resp.json().get("sha", "")[:8]
2868
+ store.record_fix(fp, "applied", branch=branch, pr_url=pr_url,
2869
+ repo_name=repo_name, commit_hash=sha)
2870
+ logger.info("Boss merge_pr: merged (after rebase) PR
2871
+ pr_number, repo_name, user_id)
2872
+ return json.dumps({
2873
+ "status": "merged",
2874
+ "pr": pr_url,
2875
+ "sha": sha,
2876
+ "repo": repo_name,
2877
+ "note": f"PR
2878
+ })
2879
+ return json.dumps({"status": "error", "pr": pr_url,
2880
+ "error": f"Retry merge failed ({retry_resp.status_code}): {retry_resp.text[:300]}"})
2881
+ return json.dumps({"status": "error", "pr": pr_url,
2882
+ "error": f"GitHub API returned {merge_resp.status_code}: {merge_resp.text[:300]}"})
2883
+ if name == "install_tool":
2884
+ import subprocess as _sp
2885
+ import os as _os
2886
+ tool_name = inputs.get("tool_name", "").strip()
2887
+ if not tool_name:
2888
+ return json.dumps({"error": "tool_name is required"})
2889
+ bin_path = cfg_loader.sentinel.claude_code_bin
2890
+ api_key = cfg_loader.sentinel.anthropic_api_key
2891
+ env = {**_os.environ}
2892
+ if api_key:
2893
+ env["ANTHROPIC_API_KEY"] = api_key
2894
+ prompt = (
2895
+ f"Install '{tool_name}' on this server so it can be used as a build/test tool. "
2896
+ f"Detect the OS and package manager (yum/dnf/apt), then run the appropriate install command. "
2897
+ f"After installing, verify the installation by running '{tool_name} --version' or the equivalent. "
2898
+ f"Report the installed version. Do not explain — just install and report."
2899
+ )
2900
+ logger.info("Boss install_tool: installing '%s' via Claude Code", tool_name)
2901
+ try:
2902
+ result = _sp.run(
2903
+ [bin_path, "--dangerously-skip-permissions", "--bare", "--print", prompt],
2904
+ capture_output=True, text=True, timeout=300, env=env,
2905
+ )
2906
+ output = ((result.stdout or "") + (result.stderr or "")).strip()
2907
+ success = result.returncode == 0
2908
+ logger.info("Boss install_tool: '%s' install %s:\n%s", tool_name, "OK" if success else "FAILED", output[-500:])
2909
+ return json.dumps({
2910
+ "status": "installed" if success else "failed",
2911
+ "tool": tool_name,
2912
+ "output": output[-1000:],
2913
+ })
2914
+ except FileNotFoundError:
2915
+ return json.dumps({"error": f"Claude Code binary not found at '{bin_path}'"})
2916
+ except _sp.TimeoutExpired:
2917
+ return json.dumps({"error": f"Install timed out for '{tool_name}'"})
2918
+ if name == "list_renovate_prs":
2919
+ import requests as _req
2920
+ from datetime import datetime as _dt_rpr, timezone as _tz_rpr
2921
+ github_token = cfg_loader.sentinel.github_token
2922
+ if not github_token:
2923
+ return json.dumps({"error": "GITHUB_TOKEN not configured — cannot query GitHub API"})
2924
+ filter_repo = inputs.get("repo_name", "").strip()
2925
+ ready_only = bool(inputs.get("ready_only", False))
2926
+ headers = {
2927
+ "Authorization": f"Bearer {github_token}",
2928
+ "Accept": "application/vnd.github+json",
2929
+ }
2930
+ def _owner_repo_from_url(url):
2931
+ if url.startswith("git@"):
2932
+ return url.split(":")[-1].removesuffix(".git")
2933
+ return "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
2934
+ def _ci_status(owner_repo, sha):
2935
+ r = _req.get(
2936
+ f"https://api.github.com/repos/{owner_repo}/commits/{sha}/check-runs",
2937
+ headers=headers, params={"per_page": 20}, timeout=10,
2938
+ )
2939
+ if r.status_code != 200:
2940
+ return "unknown"
2941
+ runs = r.json().get("check_runs", [])
2942
+ if not runs:
2943
+ return "no checks"
2944
+ statuses = {run["conclusion"] for run in runs if run["status"] == "completed"}
2945
+ if "failure" in statuses or "cancelled" in statuses:
2946
+ return "failing"
2947
+ if all(s == "success" for s in statuses) and len(statuses) > 0:
2948
+ return "passing"
2949
+ return "pending"
2950
+ def _parse_renovate_body(body):
2951
+ if not body:
2952
+ return [], "unknown"
2953
+ conf = "unknown"
2954
+ conf_m = __import__("re").search(r"\| *(low|moderate|high|neutral|very high) *\|", body, __import__("re").IGNORECASE)
2955
+ if conf_m:
2956
+ conf = conf_m.group(1).lower()
2957
+ changes = __import__("re").findall(r"\[([^\]]+)\].*?(\d+[\.\d]*)\s*[→\-]+\s*(\d+[\.\d]*)", body)
2958
+ pkgs = [{"package": p, "from": f, "to": t} for p, f, t in changes[:5]]
2959
+ return pkgs, conf
2960
+ all_prs = []
2961
+ repos_to_scan = {
2962
+ name: repo for name, repo in cfg_loader.repos.items()
2963
+ if (not filter_repo) or (filter_repo.lower() in name.lower())
2964
+ }
2965
+ for repo_name_k, repo in repos_to_scan.items():
2966
+ if not repo.repo_url:
2967
+ continue
2968
+ owner_repo = _owner_repo_from_url(repo.repo_url)
2969
+ try:
2970
+ r = _req.get(
2971
+ f"https://api.github.com/repos/{owner_repo}/pulls",
2972
+ headers=headers,
2973
+ params={"state": "open", "per_page": 50},
2974
+ timeout=15,
2975
+ )
2976
+ if r.status_code != 200:
2977
+ continue
2978
+ for pr in r.json():
2979
+ labels = [l["name"] for l in pr.get("labels", [])]
2980
+ if "renovate" not in labels:
2981
+ continue
2982
+ sha = pr["head"]["sha"]
2983
+ ci = _ci_status(owner_repo, sha)
2984
+ mergeable = pr.get("mergeable")
2985
+ conflict = (mergeable is False)
2986
+ pkgs, conf = _parse_renovate_body(pr.get("body", ""))
2987
+ created = pr.get("created_at", "")
2988
+ age_days = 0
2989
+ if created:
2990
+ try:
2991
+ dt = _dt_rpr.fromisoformat(created.replace("Z", "+00:00"))
2992
+ age_days = (_dt_rpr.now(_tz_rpr.utc) - dt).days
2993
+ except Exception:
2994
+ pass
2995
+ ready = (ci == "passing" and not conflict)
2996
+ if ready_only and not ready:
2997
+ continue
2998
+ all_prs.append({
2999
+ "repo": repo_name_k,
3000
+ "pr_number": pr["number"],
3001
+ "title": pr["title"],
3002
+ "url": pr["html_url"],
3003
+ "packages": pkgs,
3004
+ "confidence": conf,
3005
+ "ci": ci,
3006
+ "conflict": conflict,
3007
+ "age_days": age_days,
3008
+ "ready_to_merge": ready,
3009
+ })
3010
+ except Exception as exc:
3011
+ logger.warning("list_renovate_prs: error scanning %s: %s", repo_name_k, exc)
3012
+ all_prs.sort(key=lambda p: (0 if p["ready_to_merge"] else 1, p["repo"]))
3013
+ return json.dumps({
3014
+ "total": len(all_prs),
3015
+ "ready_count": sum(1 for p in all_prs if p["ready_to_merge"]),
3016
+ "prs": all_prs,
3017
+ "note": "Use merge_pr with repo_name + pr_number to merge a specific PR." if all_prs else "No open Renovate PRs found.",
3018
+ })
3019
+ if name == "manage_release":
3020
+ from .dependency_manager import plan_cascade, execute_cascade, get_artifact_id, get_release_version
3021
+ from .cicd_trigger import trigger as cicd_trigger, _trigger_jenkins, _trigger_jenkins_release
3022
+ from .notify import notify_cascade_started, notify_cascade_result
3023
+ operation = inputs.get("operation", "").strip()
3024
+ source_repo = inputs.get("source_repo", "").strip()
3025
+ target_repos = inputs.get("target_repos") or []
3026
+ confirmed = bool(inputs.get("confirmed", False))
3027
+ repo = cfg_loader.repos.get(source_repo)
3028
+ if not repo:
3029
+ for rname in cfg_loader.repos:
3030
+ if source_repo.lower() in rname.lower():
3031
+ repo = cfg_loader.repos[rname]
3032
+ source_repo = rname
3033
+ break
3034
+ if not repo:
3035
+ return json.dumps({"error": f"Repo not found: {source_repo}. Known repos: {list(cfg_loader.repos.keys())}"})
3036
+ if not confirmed:
3037
+ if operation == "build":
3038
+ return json.dumps({
3039
+ "plan": f"Trigger Jenkins build for {source_repo}",
3040
+ "job_url": repo.cicd_job_url,
3041
+ "note": "This triggers a regular build, not a release.",
3042
+ "confirm_prompt": "Reply with confirmed=true to proceed.",
3043
+ })
3044
+ if operation in ("release", "release_and_cascade"):
3045
+ cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_repos or None)
3046
+ if "error" in cascade_plan:
3047
+ return json.dumps(cascade_plan)
3048
+ cascade_note = (
3049
+ f"After release, will update {len(cascade_plan['dependents'])} dependent repo(s)."
3050
+ if cascade_plan["dependents"] else "No dependent repos found in config."
3051
+ )
3052
+ return json.dumps({
3053
+ "plan": f"Trigger Maven Release for {source_repo}",
3054
+ "release_version": cascade_plan["new_version"],
3055
+ "dev_version_after": cascade_plan.get("new_version", ""),
3056
+ "job_url": repo.cicd_job_url,
3057
+ "cascade": cascade_plan["dependents"],
3058
+ "cascade_note": cascade_note,
3059
+ "auto_publish_source": repo.auto_publish,
3060
+ "confirm_prompt": "Reply with confirmed=true to proceed.",
3061
+ })
3062
+ if operation == "update_deps":
3063
+ cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_repos or None)
3064
+ if "error" in cascade_plan:
3065
+ return json.dumps(cascade_plan)
3066
+ if not cascade_plan["dependents"]:
3067
+ return json.dumps({"note": f"No repos depend on {cascade_plan['artifact_id']} — nothing to update."})
3068
+ return json.dumps({
3069
+ "plan": f"Update {cascade_plan['artifact_id']} to {cascade_plan['new_version']} in dependent repos",
3070
+ "artifact_id": cascade_plan["artifact_id"],
3071
+ "new_version": cascade_plan["new_version"],
3072
+ "targets": cascade_plan["dependents"],
3073
+ "confirm_prompt": "Reply with confirmed=true to proceed.",
3074
+ })
3075
+ if operation == "build":
3076
+ success = _trigger_jenkins(repo)
3077
+ logger.info("Boss manage_release: build triggered for %s by %s", source_repo, user_id)
3078
+ return json.dumps({"status": "triggered" if success else "failed", "repo": source_repo, "job_url": repo.cicd_job_url})
3079
+ if operation in ("release", "release_and_cascade"):
3080
+ success = _trigger_jenkins_release(repo)
3081
+ logger.info("Boss manage_release: release triggered for %s by %s (success=%s)", source_repo, user_id, success)
3082
+ if not success:
3083
+ return json.dumps({"status": "failed", "repo": source_repo, "error": "Jenkins release trigger failed — check logs"})
3084
+ do_cascade = (operation == "release_and_cascade") or repo.auto_publish
3085
+ if do_cascade:
3086
+ artifact_id = get_artifact_id(repo.local_path)
3087
+ new_version = get_release_version(repo.local_path)
3088
+ if artifact_id and new_version:
3089
+ target_names = target_repos or None
3090
+ cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_names)
3091
+ target_repo_names = [d["repo"] for d in cascade_plan.get("dependents", [])]
3092
+ if target_repo_names:
3093
+ notify_cascade_started(cfg_loader.sentinel, artifact_id, new_version, target_repo_names, user_id)
3094
+ results = execute_cascade(source_repo, new_version, artifact_id, cfg_loader.repos, cfg_loader.sentinel, target_names)
3095
+ notify_cascade_result(cfg_loader.sentinel, artifact_id, new_version, results, user_id)
3096
+ return json.dumps({
3097
+ "status": "released_and_cascaded",
3098
+ "repo": source_repo,
3099
+ "version": new_version,
3100
+ "cascade": [{"repo": r.repo_name, "success": r.success, "pr_url": r.pr_url, "error": r.error} for r in results],
3101
+ })
3102
+ return json.dumps({"status": "released", "repo": source_repo, "note": "Cascade will run automatically on next poll if AUTO_PUBLISH=true."})
3103
+ if operation == "update_deps":
3104
+ artifact_id = get_artifact_id(repo.local_path)
3105
+ new_version = get_release_version(repo.local_path)
3106
+ if not artifact_id or not new_version:
3107
+ return json.dumps({"error": f"Could not read artifact/version from {source_repo}/pom.xml"})
3108
+ target_names = target_repos or None
3109
+ cascade_plan = plan_cascade(source_repo, cfg_loader.repos, target_names)
3110
+ target_repo_names = [d["repo"] for d in cascade_plan.get("dependents", [])]
3111
+ if not target_repo_names:
3112
+ return json.dumps({"note": f"No repos depend on {artifact_id} — nothing to update."})
3113
+ notify_cascade_started(cfg_loader.sentinel, artifact_id, new_version, target_repo_names, user_id)
3114
+ results = execute_cascade(source_repo, new_version, artifact_id, cfg_loader.repos, cfg_loader.sentinel, target_names)
3115
+ notify_cascade_result(cfg_loader.sentinel, artifact_id, new_version, results, user_id)
3116
+ return json.dumps({
3117
+ "status": "updated",
3118
+ "artifact_id": artifact_id,
3119
+ "version": new_version,
3120
+ "results": [{"repo": r.repo_name, "success": r.success, "pr_url": r.pr_url, "error": r.error} for r in results],
3121
+ })
3122
+ return json.dumps({"error": f"Unknown operation: {operation}"})
3123
+ if name == "chain_release":
3124
+ from .dependency_manager import get_artifact_id, get_release_version, update_dependency
3125
+ from .git_manager import commit_file_change, push_dep_update, _git, _git_env, maven_compile_check, MissingToolError
3126
+ from .cicd_trigger import _trigger_jenkins_release
3127
+ from .notify import slack_alert
3128
+ chain_repos = inputs.get("chain", [])
3129
+ confirmed = bool(inputs.get("confirmed", False))
3130
+ if not chain_repos or len(chain_repos) < 2:
3131
+ return json.dumps({"error": "chain must contain at least 2 repos"})
3132
+ resolved = []
3133
+ for rname in chain_repos:
3134
+ repo = cfg_loader.repos.get(rname)
3135
+ if not repo:
3136
+ for k, v in cfg_loader.repos.items():
3137
+ if rname.lower() in k.lower():
3138
+ repo = v
3139
+ rname = k
3140
+ break
3141
+ if not repo:
3142
+ return json.dumps({"error": f"Repo not found: {rname}. Known: {list(cfg_loader.repos.keys())}"})
3143
+ resolved.append(repo)
3144
+ steps = []
3145
+ for repo in resolved:
3146
+ artifact_id = get_artifact_id(repo.local_path) if repo.local_path else ""
3147
+ release_ver = get_release_version(repo.local_path) if repo.local_path else ""
3148
+ steps.append({
3149
+ "repo": repo.repo_name,
3150
+ "artifact_id": artifact_id,
3151
+ "release_version": release_ver,
3152
+ "cicd_url": repo.cicd_job_url,
3153
+ "auto_publish": repo.auto_publish,
3154
+ })
3155
+ if not confirmed:
3156
+ plan_steps = []
3157
+ for i, step in enumerate(steps):
3158
+ if i == 0:
3159
+ desc = f"Release {step['repo']} v{step['release_version']}"
3160
+ else:
3161
+ prev = steps[i - 1]
3162
+ desc = (
3163
+ f"Update {step['repo']} dep on {prev['artifact_id']} "
3164
+ f"→ {prev['release_version']}, then release v{step['release_version']}"
3165
+ )
3166
+ plan_steps.append({
3167
+ "step": i + 1,
3168
+ "action": desc,
3169
+ "repo": step["repo"],
3170
+ "release_version": step["release_version"],
3171
+ "jenkins_url": step["cicd_url"],
3172
+ "mode": "push to main" if step["auto_publish"] else "open PR",
3173
+ })
3174
+ return json.dumps({
3175
+ "plan": f"Sequential release chain: {' → '.join(s['repo'] for s in steps)}",
3176
+ "steps": plan_steps,
3177
+ "confirm_prompt": "Reply with confirmed=true to execute all steps in sequence.",
3178
+ })
3179
+ cfg = cfg_loader.sentinel
3180
+ slack_alert(
3181
+ cfg.slack_bot_token, cfg.slack_channel,
3182
+ f":chains: *Chain release started* ({len(steps)} steps)\n"
3183
+ + " \u2192 ".join(s["repo"] for s in steps),
3184
+ )
3185
+ results = []
3186
+ for i, (step, repo) in enumerate(zip(steps, resolved)):
3187
+ step_label = f"Step {i + 1}/{len(steps)}"
3188
+ if i > 0:
3189
+ prev = steps[i - 1]
3190
+ if not prev["artifact_id"] or not prev["release_version"]:
3191
+ msg = f"{step_label}: skipped — could not read artifact/version from {prev['repo']}"
3192
+ results.append({"step": i + 1, "repo": step["repo"], "status": "skipped", "error": msg})
3193
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, f":warning: {msg}")
3194
+ break
3195
+ _git(["checkout", "pom.xml"], cwd=repo.local_path, env=_git_env(repo))
3196
+ pull_r = _git(["pull", "--rebase", "origin", repo.branch], cwd=repo.local_path, env=_git_env(repo))
3197
+ if pull_r.returncode != 0:
3198
+ msg = f"{step_label}: git pull failed for {repo.repo_name}"
3199
+ results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
3200
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, ":x: Chain release failed — " + msg)
3201
+ break
3202
+ changed = update_dependency(repo.local_path, prev["artifact_id"], prev["release_version"])
3203
+ if changed:
3204
+ commit_msg = (
3205
+ f"chore(deps): update {prev['artifact_id']} to {prev['release_version']} [sentinel-chain]\n\n"
3206
+ f"Part of release chain: {' -> '.join(s['repo'] for s in steps)}"
3207
+ )
3208
+ try:
3209
+ compile_ok, compile_out = maven_compile_check(repo.local_path)
3210
+ except MissingToolError:
3211
+ compile_ok, compile_out = False, "mvn not installed"
3212
+ if not compile_ok:
3213
+ _git(["checkout", "pom.xml"], cwd=repo.local_path, env=_git_env(repo))
3214
+ msg = f"{step_label}: Maven compile failed for {repo.repo_name} — pom.xml reverted"
3215
+ results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
3216
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, ":x: Chain release failed — " + msg)
3217
+ break
3218
+ status, commit_hash = commit_file_change(repo, ["pom.xml"], commit_msg, skip_pull=True)
3219
+ if status != "committed":
3220
+ msg = f"{step_label}: git commit failed for {repo.repo_name}"
3221
+ results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
3222
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
3223
+ f":x: *Chain release failed at {step_label}*\n{msg}")
3224
+ break
3225
+ push_r = _git(["push", "origin", repo.branch], cwd=repo.local_path, env=_git_env(repo))
3226
+ push_ok = push_r.returncode == 0
3227
+ branch = repo.branch
3228
+ pr_url = ""
3229
+ if not push_ok:
3230
+ msg = (f"{step_label}: git push to {repo.branch} failed for {repo.repo_name}: "
3231
+ + push_r.stderr.strip()[:200])
3232
+ results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
3233
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
3234
+ ":x: Chain release failed: " + msg)
3235
+ break
3236
+ action = f"pushed to `{branch}`"
3237
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
3238
+ f":arrow_right: {step_label}: Updated `{repo.repo_name}` "
3239
+ f"`{prev['artifact_id']}` \u2192 `{prev['release_version']}` ({action})")
3240
+ else:
3241
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
3242
+ f":information_source: {step_label}: `{repo.repo_name}` dep already at "
3243
+ f"`{prev['release_version']}` — skipping pom.xml update")
3244
+ if repo.cicd_job_url and repo.cicd_type:
3245
+ from .cicd_trigger import _maven_release_versions
3246
+ actual_ver, _ = _maven_release_versions(repo.local_path)
3247
+ actual_ver = actual_ver or step["release_version"]
3248
+ success = _trigger_jenkins_release(repo, wait=True)
3249
+ if success:
3250
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
3251
+ f":rocket: {step_label}: Jenkins release completed — "
3252
+ f"`{repo.repo_name}` v{actual_ver}")
3253
+ results.append({"step": i + 1, "repo": step["repo"], "status": "released",
3254
+ "version": actual_ver})
3255
+ else:
3256
+ msg = f"Jenkins release trigger failed for {repo.repo_name}"
3257
+ results.append({"step": i + 1, "repo": step["repo"], "status": "failed", "error": msg})
3258
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
3259
+ f":x: *Chain release failed at {step_label}*\n{msg}")
3260
+ break
3261
+ else:
3262
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel,
3263
+ f":information_source: {step_label}: `{repo.repo_name}` has no CI/CD — "
3264
+ f"pom.xml updated but Jenkins not triggered")
3265
+ results.append({"step": i + 1, "repo": step["repo"], "status": "no_cicd",
3266
+ "note": "pom.xml updated, no Jenkins trigger configured"})
3267
+ all_ok = all(r.get("status") in ("released", "no_cicd") for r in results)
3268
+ final = "completed" if all_ok else "partial" if results else "failed"
3269
+ _icon = ":white_check_mark:" if all_ok else ":warning:"
3270
+ _steps = ", ".join(
3271
+ "`" + r["repo"] + "` " + ("✓" if r.get("status") in ("released", "no_cicd") else "✗")
3272
+ for r in results
3273
+ )
3274
+ slack_alert(
3275
+ cfg.slack_bot_token, cfg.slack_channel,
3276
+ f"{_icon}: *Chain release {final}* — {_steps}",
3277
+ )
3278
+ logger.info("Boss chain_release: %s by %s — %s", final, user_id, results)
3279
+ return json.dumps({"status": final, "steps": results})
3280
+ return json.dumps({"error": f"unknown tool: {name}"})
3281
+ def _attachments_to_text(attachments: list[dict]) -> str:
3282
+ if not attachments:
3283
+ return ""
3284
+ parts = []
3285
+ for att in attachments:
3286
+ if att["type"] == "text":
3287
+ parts.append(
3288
+ f"[Attached file: {att['name']}]\n{att['content']}"
3289
+ )
3290
+ elif att["type"] == "image":
3291
+ parts.append(
3292
+ f"[Attached image: {att['name']}] (saved at {att['path']})"
3293
+ )
3294
+ else:
3295
+ parts.append(
3296
+ f"[Attached file: {att['name']}] (saved at {att['path']} — read it if relevant)"
3297
+ )
3298
+ return "\n\nATTACHMENTS:\n" + "\n---\n".join(parts)
3299
+ def _attachments_to_api_blocks(attachments: list[dict]) -> list[dict]:
3300
+ blocks: list[dict] = []
3301
+ for att in attachments:
3302
+ if att["type"] == "image":
3303
+ blocks.append({
3304
+ "type": "image",
3305
+ "source": {
3306
+ "type": "base64",
3307
+ "media_type": att.get("mime", "image/png"),
3308
+ "data": att["content"],
3309
+ },
3310
+ })
3311
+ elif att["type"] == "text":
3312
+ blocks.append({
3313
+ "type": "text",
3314
+ "text": f"[Attached file: {att['name']}]\n{att['content']}",
3315
+ })
3316
+ else:
3317
+ blocks.append({
3318
+ "type": "text",
3319
+ "text": f"[Attached file: {att['name']}] saved at {att['path']}",
3320
+ })
3321
+ return blocks
3322
+ _ACTION_RE = re.compile(r"^ACTION:\s*(\{.*\})", re.MULTILINE)
3323
+ async def _handle_with_cli(
3324
+ message: str,
3325
+ history: list,
3326
+ cfg_loader,
3327
+ store,
3328
+ slack_client=None,
3329
+ user_name: str = "",
3330
+ user_id: str = "",
3331
+ attachments: list | None = None,
3332
+ is_admin: bool = False,
3333
+ ) -> tuple[str, bool]:
3334
+ status_json = await _run_tool("get_status", {"hours": 24}, cfg_loader, store)
3335
+ prs_json = await _run_tool("list_pending_prs", {}, cfg_loader, store)
3336
+ search_json = ""
3337
+ _search_kws = ("search", "find", "look for", "show me log", "grep", "entries for")
3338
+ if any(kw in message.lower() for kw in _search_kws):
3339
+ quoted = re.findall(r'"([^"]+)"', message)
3340
+ query = quoted[0] if quoted else message
3341
+ search_json = await _run_tool("search_logs", {"query": query}, cfg_loader, store)
3342
+ paused = Path("SENTINEL_PAUSE").exists()
3343
+ repos = list(cfg_loader.repos.keys())
3344
+ log_sources = list(cfg_loader.log_sources.keys())
3345
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
3346
+ user_tz = store.get_user_tz(user_id) if user_id else ""
3347
+ if user_tz:
3348
+ try:
3349
+ import zoneinfo as _zi
3350
+ _local = datetime.now(_zi.ZoneInfo(user_tz)).strftime("%Y-%m-%d %H:%M")
3351
+ user_time_hint = f"User's local time ({user_tz}): {_local}"
3352
+ except Exception:
3353
+ user_time_hint = ""
3354
+ else:
3355
+ user_time_hint = ""
3356
+ history_text = ""
3357
+ for msg in history[-8:]:
3358
+ role = msg["role"].upper()
3359
+ content = msg["content"]
3360
+ if isinstance(content, list):
3361
+ content = " ".join(
3362
+ (b.get("text", "") if isinstance(b, dict) else getattr(b, "text", ""))
3363
+ for b in content
3364
+ if (isinstance(b, dict) and b.get("type") == "text")
3365
+ or (hasattr(b, "type") and b.type == "text")
3366
+ )
3367
+ history_text += f"\n{role}: {content}"
3368
+ slack_mention = f"<@{user_id}>" if user_id else (user_name or "")
3369
+ known_users = store.get_all_users()
3370
+ users_hint = ", ".join(f"<@{uid}> = {name}" for uid, name in known_users.items())
3371
+ prompt = (
3372
+ _resolve_system(
3373
+ boss_mode=getattr(cfg_loader.sentinel, "boss_mode", "standard"),
3374
+ project_name=cfg_loader.sentinel.project_name or _read_project_name(Path(".")),
3375
+ project_description=getattr(cfg_loader.sentinel, "project_description", ""),
3376
+ other_project_names=[_read_project_name(d) for d in _find_project_dirs()],
3377
+ )
3378
+ + (f"\nYou are speaking with: {user_name} (Slack mention: {slack_mention})" if user_name else "")
3379
+ + "\nAlways start your reply by addressing the user directly using their Slack mention, e.g. \"<@U123> here is what I found...\"."
3380
+ + " Never use their plain name — always use the <@USER_ID> format so Slack highlights it."
3381
+ + (f"\nKnown Slack users: {users_hint}" if users_hint else "")
3382
+ + f"\n\nCurrent time (UTC): {ts}"
3383
+ + (f"\n{user_time_hint}" if user_time_hint else "")
3384
+ + f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
3385
+ + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
3386
+ + (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
3387
+ + f"\nAdmin access for this user: {'YES — admin tools are available' if is_admin else 'NO — admin tools will be refused'}"
3388
+ + "\nNOTE: Running in CLI fallback mode — admin tools and some features are unavailable. Ask user to configure ANTHROPIC_API_KEY for full features."
3389
+ + f"\n\nCurrent status (last 24 h):\n{status_json}"
3390
+ + f"\n\nOpen PRs:\n{prs_json}"
3391
+ + (f"\n\nLog search results:\n{search_json}" if search_json else "")
3392
+ + (f"\n\nConversation so far:{history_text}" if history_text else "")
3393
+ + _attachments_to_text(attachments or [])
3394
+ + f"\n\nUSER: {message}"
3395
+ + "\n\nIf you need to take an action, include a line like:\n"
3396
+ + " ACTION: {\"action\": \"pause_sentinel\"}\n"
3397
+ + " ACTION: {\"action\": \"resume_sentinel\"}\n"
3398
+ + " ACTION: {\"action\": \"trigger_poll\"}\n"
3399
+ + " ACTION: {\"action\": \"create_issue\", \"description\": \"...\", \"target_repo\": \"\"}\n"
3400
+ + " ACTION: {\"action\": \"search_logs\", \"query\": \"<whatever the user asked to find>\"}\n"
3401
+ + "End with [DONE] if the request is fully handled."
3402
+ )
3403
+ cfg = cfg_loader.sentinel
3404
+ env = os.environ.copy()
3405
+ if cfg.anthropic_api_key:
3406
+ env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
3407
+ try:
3408
+ result = subprocess.run(
3409
+ ([cfg.claude_code_bin, "--dangerously-skip-permissions", "--bare", "--print", prompt]
3410
+ if os.getuid() != 0 else
3411
+ [cfg.claude_code_bin, "--bare", "--print", prompt]),
3412
+ capture_output=True, text=True, timeout=180, env=env, stdin=subprocess.DEVNULL,
3413
+ )
3414
+ output = (result.stdout or "").strip()
3415
+ if result.returncode != 0 or not output:
3416
+ stderr = (result.stderr or "").strip()
3417
+ logger.error(
3418
+ "Boss CLI call failed (rc=%d): stdout=%r stderr=%r",
3419
+ result.returncode, output[:200], stderr[:200],
3420
+ )
3421
+ raw_err = (result.stderr or "").strip()
3422
+ if result.returncode != 0 and not output:
3423
+ full_err = f"exit {result.returncode}: {raw_err[:300]}"
3424
+ cfg = cfg_loader.sentinel
3425
+ alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
3426
+ "sentinel_boss/cli", raw_err or full_err)
3427
+ return f":warning: `claude --print` failed ({full_err})", True
3428
+ except Exception as e:
3429
+ logger.error("Boss CLI call failed: %s", e)
3430
+ return f":warning: Boss unavailable: {e}", True
3431
+ tools_ran = []
3432
+ for m in _ACTION_RE.finditer(output):
3433
+ try:
3434
+ action = json.loads(m.group(1))
3435
+ name = action.pop("action", "")
3436
+ if name:
3437
+ result_str = await _run_tool(name, action, cfg_loader, store, user_id=user_id)
3438
+ tools_ran.append(name)
3439
+ logger.info("Boss CLI action: %s → %s", name, result_str[:80])
3440
+ except Exception as e:
3441
+ logger.warning("Boss action parse error: %s", e)
3442
+ reply = _ACTION_RE.sub("", output).strip()
3443
+ is_done = "[DONE]" in reply
3444
+ reply = reply.replace("[DONE]", "").strip()
3445
+ if not reply:
3446
+ if tools_ran:
3447
+ reply = f":white_check_mark: Done ({', '.join(tools_ran)})."
3448
+ elif len(message.strip()) <= 12 and not any(c.isalpha() and c not in 'helo wrdHELOWRD' for c in message):
3449
+ greeting = f"Hi {user_name}! " if user_name else "Hi! "
3450
+ reply = f"{greeting}I'm Sentinel, your autonomous DevOps agent. How can I help you?"
3451
+ else:
3452
+ reply = ":thinking_face: I didn't produce a response — please try rephrasing."
3453
+ history.append({"role": "user", "content": message})
3454
+ history.append({"role": "assistant", "content": reply})
3455
+ return reply, is_done
3456
+ def _serialize_content(content) -> list:
3457
+ if not isinstance(content, list):
3458
+ return content
3459
+ result = []
3460
+ for block in content:
3461
+ if isinstance(block, dict):
3462
+ result.append(block)
3463
+ elif hasattr(block, "model_dump"):
3464
+ result.append(block.model_dump())
3465
+ elif hasattr(block, "dict"):
3466
+ result.append(block.dict())
3467
+ elif hasattr(block, "type"):
3468
+ if block.type == "text":
3469
+ result.append({"type": "text", "text": getattr(block, "text", "")})
3470
+ elif block.type == "tool_use":
3471
+ result.append({
3472
+ "type": "tool_use",
3473
+ "id": getattr(block, "id", ""),
3474
+ "name": getattr(block, "name", ""),
3475
+ "input": getattr(block, "input", {}),
3476
+ })
3477
+ else:
3478
+ result.append({"type": "text", "text": str(block)})
3479
+ return result
3480
+ def _clean_history(history: list) -> list:
3481
+ cleaned = []
3482
+ i = 0
3483
+ while i < len(history):
3484
+ turn = history[i]
3485
+ role = turn.get("role", "")
3486
+ content = turn.get("content", [])
3487
+ if role == "assistant" and isinstance(content, list):
3488
+ has_tool_use = any(
3489
+ (isinstance(b, dict) and b.get("type") == "tool_use")
3490
+ for b in content
3491
+ )
3492
+ if has_tool_use:
3493
+ next_turn = history[i + 1] if i + 1 < len(history) else None
3494
+ next_content = (next_turn or {}).get("content", [])
3495
+ has_result = isinstance(next_content, list) and any(
3496
+ (isinstance(b, dict) and b.get("type") == "tool_result")
3497
+ for b in next_content
3498
+ )
3499
+ if not has_result:
3500
+ i += 1
3501
+ continue
3502
+ if cleaned and cleaned[-1].get("role") == role:
3503
+ cleaned[-1] = turn
3504
+ else:
3505
+ cleaned.append(turn)
3506
+ i += 1
3507
+ return cleaned
3508
+ async def _handle_with_api(
3509
+ message: str,
3510
+ history: list,
3511
+ cfg_loader,
3512
+ store,
3513
+ slack_client=None,
3514
+ user_name: str = "",
3515
+ user_id: str = "",
3516
+ attachments: list | None = None,
3517
+ channel: str = "",
3518
+ is_admin: bool = False,
3519
+ ) -> tuple[str, bool]:
3520
+ import anthropic
3521
+ api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
3522
+ client = anthropic.Anthropic(api_key=api_key)
3523
+ paused = Path("SENTINEL_PAUSE").exists()
3524
+ repos = list(cfg_loader.repos.keys())
3525
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
3526
+ known_projects = [_read_project_name(d) for d in _find_project_dirs()]
3527
+ log_sources = list(cfg_loader.log_sources.keys())
3528
+ slack_mention = f"<@{user_id}>" if user_id else (user_name or "")
3529
+ known_users = store.get_all_users()
3530
+ users_hint = ", ".join(f"<@{uid}> = {name}" for uid, name in known_users.items())
3531
+ user_tz = store.get_user_tz(user_id) if user_id else ""
3532
+ if user_tz:
3533
+ try:
3534
+ import zoneinfo as _zi
3535
+ _local = datetime.now(_zi.ZoneInfo(user_tz)).strftime("%Y-%m-%d %H:%M")
3536
+ user_time_hint = f"User's local time ({user_tz}): {_local}"
3537
+ except Exception:
3538
+ user_time_hint = ""
3539
+ else:
3540
+ user_time_hint = ""
3541
+ _known_projects = [_read_project_name(d) for d in _find_project_dirs()]
3542
+ system = (
3543
+ _resolve_system(
3544
+ boss_mode=getattr(cfg_loader.sentinel, "boss_mode", "standard"),
3545
+ project_name=cfg_loader.sentinel.project_name or _read_project_name(Path(".")),
3546
+ project_description=getattr(cfg_loader.sentinel, "project_description", ""),
3547
+ other_project_names=_known_projects,
3548
+ )
3549
+ + (f"\nYou are speaking with: {user_name} (Slack mention: {slack_mention})" if user_name else "")
3550
+ + "\nAlways start your reply by addressing the user directly using their Slack mention, e.g. \"<@U123> here is what I found...\"."
3551
+ + " Never use their plain name — always use the <@USER_ID> format so Slack highlights it."
3552
+ + (f"\nKnown Slack users: {users_hint}" if users_hint else "")
3553
+ + f"\n\nCurrent time (UTC): {ts}"
3554
+ + (f"\n{user_time_hint}" if user_time_hint else "")
3555
+ + f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
3556
+ + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
3557
+ + (f"\nLog sources: {', '.join(log_sources)}" if log_sources else "")
3558
+ + (f"\nKnown projects in workspace: {', '.join(known_projects)}" if known_projects else "")
3559
+ + f"\nAdmin access for this user: {'YES — admin tools are available' if is_admin else 'NO — admin tools will be refused'}"
3560
+ )
3561
+ attach_blocks = _attachments_to_api_blocks(attachments or [])
3562
+ if attach_blocks:
3563
+ user_content = attach_blocks + [{"type": "text", "text": message}]
3564
+ else:
3565
+ user_content = message
3566
+ messages = list(history) + [{"role": "user", "content": user_content}]
3567
+ while True:
3568
+ response = client.messages.create(
3569
+ model="claude-opus-4-6",
3570
+ max_tokens=2048,
3571
+ system=system,
3572
+ tools=_TOOLS,
3573
+ messages=messages,
3574
+ )
3575
+ text_parts = []
3576
+ tool_blocks = []
3577
+ for block in response.content:
3578
+ if block.type == "text":
3579
+ text_parts.append(block.text)
3580
+ elif block.type == "tool_use":
3581
+ tool_blocks.append(block)
3582
+ if not tool_blocks:
3583
+ reply = " ".join(text_parts).strip()
3584
+ is_done = "[DONE]" in reply
3585
+ reply = reply.replace("[DONE]", "").strip()
3586
+ if not reply:
3587
+ tools_ran_this_session = any(
3588
+ m.get("role") == "user" and isinstance(m.get("content"), list)
3589
+ and any(r.get("type") == "tool_result" for r in m["content"])
3590
+ for m in messages
3591
+ )
3592
+ if tools_ran_this_session:
3593
+ reply = ":white_check_mark: Done."
3594
+ elif len(message.strip()) <= 12:
3595
+ greeting = f"Hi {user_name}! " if user_name else "Hi! "
3596
+ reply = f"{greeting}I'm Sentinel, your autonomous DevOps agent. How can I help you?"
3597
+ else:
3598
+ reply = ":thinking_face: I didn't produce a response — please try rephrasing."
3599
+ if is_done and re.search(r'\?\s*$', reply):
3600
+ is_done = False
3601
+ history.append({"role": "user", "content": user_content})
3602
+ history.append({"role": "assistant", "content": _serialize_content(response.content)})
3603
+ return reply, is_done
3604
+ messages.append({"role": "assistant", "content": _serialize_content(response.content)})
3605
+ tool_results = []
3606
+ for tc in tool_blocks:
3607
+ result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client, user_id=user_id, channel=channel, is_admin=is_admin)
3608
+ logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
3609
+ tool_results.append({
3610
+ "type": "tool_result",
3611
+ "tool_use_id": tc.id,
3612
+ "content": result,
3613
+ })
3614
+ messages.append({"role": "user", "content": tool_results})
3615
+ async def handle_message(
3616
+ message: str,
3617
+ history: list,
3618
+ cfg_loader,
3619
+ store,
3620
+ slack_client=None,
3621
+ user_name: str = "",
3622
+ user_id: str = "",
3623
+ attachments: list | None = None,
3624
+ channel: str = "",
3625
+ is_admin: bool = False,
3626
+ ) -> tuple[str, bool]:
3627
+ api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
3628
+ if api_key:
3629
+ try:
3630
+ import anthropic
3631
+ return await _handle_with_api(
3632
+ message, history, cfg_loader, store, slack_client=slack_client,
3633
+ user_name=user_name, user_id=user_id, attachments=attachments, channel=channel,
3634
+ is_admin=is_admin,
3635
+ )
3636
+ except Exception as api_err:
3637
+ err_str = str(api_err)
3638
+ cfg = cfg_loader.sentinel
3639
+ if is_rate_limited(err_str):
3640
+ from .notify import rate_limit_message
3641
+ alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
3642
+ "sentinel_boss/api", err_str)
3643
+ logger.warning("Boss: API key path failed (%s), trying CLI fallback", err_str)
3644
+ cli_reply, cli_done = await _handle_with_cli(
3645
+ message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
3646
+ user_id=user_id, attachments=attachments, is_admin=is_admin,
3647
+ )
3648
+ if not cli_reply.startswith(":warning:"):
3649
+ return cli_reply, cli_done
3650
+ cfg = cfg_loader.sentinel
3651
+ err_output = cli_reply
3652
+ alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
3653
+ "sentinel_boss/cli", err_output)
3654
+ if not api_key:
3655
+ no_auth_msg = (
3656
+ ":warning: *Sentinel Boss — no Claude auth configured*\n"
3657
+ "Configure at least one of:\n"
3658
+ "• `ANTHROPIC_API_KEY` in `sentinel.properties` — full features\n"
3659
+ "• Claude Pro OAuth: run `claude login` on the server — required for fix_engine\n"
3660
+ "See: https://github.com/misterhuydo/Sentinel
3661
+ )
3662
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, no_auth_msg)
3663
+ return ":warning: No Claude authentication configured. See Slack for details.", True
3664
+ return cli_reply, cli_done