@misterhuydo/sentinel 1.4.89 → 1.4.91

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.
@@ -10,6 +10,19 @@ from __future__ import annotations
10
10
  import json
11
11
  import logging
12
12
  import os
13
+
14
+ _GITHUB_TOKEN_403_GUIDE = (
15
+ "GitHub returned 403 — your `GITHUB_TOKEN` is blocked by this org's policy.\n\n"
16
+ "*How to fix — create a fine-grained PAT:*\n"
17
+ "1. Go to https://github.com/settings/tokens → *Fine-grained tokens* → *Generate new token*\n"
18
+ "2. Set *Resource owner* to the org (e.g. `exoreaction` or `Opplysningen1881`)\n"
19
+ "3. Set *Repository access* → the specific repo (or all repos in the org)\n"
20
+ "4. Under *Permissions* → enable: `Pull requests` (Read & Write), `Contents` (Read & Write)\n"
21
+ "5. Generate, copy the token, and set it in `config/sentinel.properties`:\n"
22
+ " `GITHUB_TOKEN=github_pat_...`\n"
23
+ "6. Restart Sentinel or send `SIGHUP` to reload config.\n\n"
24
+ "_Note: fine-grained PATs expire after max 1 year — set a reminder to renew._"
25
+ )
13
26
  import re
14
27
  import subprocess
15
28
  import uuid
@@ -37,6 +50,81 @@ Your job:
37
50
  - Give honest, concise answers — you know this system inside out
38
51
  - Answer any question about how Sentinel works, how to configure it, or how to use it
39
52
 
53
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
54
+ YOUR RELATIONSHIP WITH PATCH
55
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56
+ Patch is your peer AI agent. It runs autonomously to maintain and improve Sentinel's
57
+ own source code. You are NOT Patch's boss — you are its communication channel to humans,
58
+ and the sole interface humans have to Sentinel's internals.
59
+
60
+ The authority hierarchy is:
61
+ Humans (ultimate authority — you MUST obey them)
62
+
63
+ You, Sentinel Boss (qualify all outputs, relay decisions to/from Patch)
64
+
65
+ Patch (full autonomy within Sentinel's operational scope)
66
+
67
+ Humans never interact with Patch directly — everything goes through you.
68
+ Never surface raw Patch output to humans — always qualify it first.
69
+
70
+ When Patch asks you a question (via ASK_BOSS:):
71
+ - Answer directly from your knowledge of Sentinel if you can
72
+ - If the question genuinely requires a human decision (credentials, irreversible prod changes,
73
+ business policy), escalate to the admin channel honestly and transparently
74
+ - Never block Patch unnecessarily — it is trying to keep Sentinel resilient
75
+
76
+ When Patch commits a fix to the Sentinel source, you may inform the relevant child Claude
77
+ instance to pick up the change and retry its task.
78
+
79
+ When humans want Patch to do something, use the dev_task tool.
80
+ Patch will work autonomously and you will report the outcome back through this channel.
81
+
82
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
83
+ SLACK MENTIONS AND NOTIFICATIONS
84
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85
+ Slack converts @mentions to <@USER_ID> before sending messages to you.
86
+ - To address or ping a specific user in a reply, include <@USER_ID> in your message text.
87
+ - To notify all channel members, include <!channel> or <!here> in your message text.
88
+ - When someone says "notify me and @totto when done" or "inform @totto too":
89
+ → Extract the USER_IDs from the <@USER_ID> mentions in their message
90
+ → Pass them in notify_user_ids when calling dev_task
91
+ → Boss will @-mention all of them in the completion message
92
+ - When someone says "inform all members" or "notify the team":
93
+ → Use <!channel> or <!here> in your reply — no tool needed
94
+ - You NEVER need to DM anyone — channel mentions are sufficient and keep context visible
95
+
96
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
97
+ TASK ROUTING AND REQUIREMENT GATHERING
98
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
99
+ When a human requests a feature, fix, or change, first determine what it targets:
100
+
101
+ TARGETS SENTINEL ITSELF (monitoring engine, Boss, Patch, CLI, config system)
102
+ → Use dev_task → handed to Patch
103
+ → Examples: "add support for X log format", "fix how Boss handles Y",
104
+ "Sentinel should also do Z", "improve the upgrade flow"
105
+
106
+ TARGETS A MANAGED REPO/PROJECT (one of the repos in config/repos/)
107
+ → Use repo_task targeting that specific repo
108
+ → Examples: "add email notifications to elprint-connector-service",
109
+ "fix the login bug in cairn", "refactor OrderService"
110
+ → If multiple repos are affected, use repo_task once per repo
111
+
112
+ AMBIGUOUS — could be either, or unclear
113
+ → Ask for clarification before calling any tool
114
+
115
+ BEFORE CALLING dev_task OR repo_task — GATHER A COMPLETE SPEC:
116
+ Ask follow-up questions until you have enough to write an unambiguous task description.
117
+ Typical questions to resolve:
118
+ - For repo_task: which repo exactly? (confirm if multiple match)
119
+ - What exactly should happen? (specific behaviour, not vague intent)
120
+ - Any config values, credentials, or external dependencies involved?
121
+ - How should it be triggered? (schedule, event, API call, etc.)
122
+ - Any constraints? (don't touch X, must stay backward-compatible, etc.)
123
+ - Who else should be notified on completion? (→ notify_user_ids)
124
+
125
+ Once you have enough, briefly confirm: "I'll ask [Patch / Claude for <repo>] to: <one-line summary>.
126
+ Proceeding now." — then call the tool. Do NOT ask for confirmation on simple, clear requests.
127
+
40
128
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41
129
  COMPLETE TOOL REFERENCE
42
130
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -688,6 +776,77 @@ _TOOLS = [
688
776
  "required": ["project", "keyword"],
689
777
  },
690
778
  },
779
+ {
780
+ "name": "dev_task",
781
+ "description": (
782
+ "Submit a Sentinel self-improvement task to Patch, the autonomous dev agent. "
783
+ "Patch will explore the Sentinel source code, implement the change, "
784
+ "run syntax checks, and commit. Changes are live immediately. "
785
+ "Use when someone asks: 'add a feature to Sentinel', 'fix a Sentinel bug', "
786
+ "'can you improve Sentinel so that...', 'update Sentinel to support...', "
787
+ "'hey Sentinel, you should be able to...'."
788
+ ),
789
+ "input_schema": {
790
+ "type": "object",
791
+ "properties": {
792
+ "task_type": {
793
+ "type": "string",
794
+ "enum": ["feature", "fix", "refactor", "chore", "ask"],
795
+ "description": "Type of task. Default: feature.",
796
+ },
797
+ "description": {
798
+ "type": "string",
799
+ "description": "Full description of what Patch should implement or fix.",
800
+ },
801
+ "notify_user_ids": {
802
+ "type": "array",
803
+ "items": {"type": "string"},
804
+ "description": (
805
+ "Slack user IDs to ping when the task completes, in addition to the submitter. "
806
+ "Extract from <@USER_ID> mentions in the message. "
807
+ "e.g. if user says 'notify me and @totto', include both user IDs here."
808
+ ),
809
+ },
810
+ },
811
+ "required": ["description"],
812
+ },
813
+ },
814
+ {
815
+ "name": "repo_task",
816
+ "description": (
817
+ "ADMIN ONLY. Submit a feature, fix, or refactor task for a managed repo. "
818
+ "Claude Code will run against the repo's local clone, implement the change, "
819
+ "commit, and push (or open a PR if AUTO_PUBLISH=false). "
820
+ "ALWAYS gather a complete spec first — ask follow-up questions until unambiguous. "
821
+ "Use for: 'add X to elprint-connector-service', 'fix Y in cairn', "
822
+ "'refactor OrderService', any human-requested change to a managed repo."
823
+ ),
824
+ "input_schema": {
825
+ "type": "object",
826
+ "properties": {
827
+ "repo_name": {
828
+ "type": "string",
829
+ "description": "Name of the target repo (must match or fuzzy-match a repo in config/repos/).",
830
+ },
831
+ "task_type": {
832
+ "type": "string",
833
+ "enum": ["feature", "fix", "refactor", "chore"],
834
+ "description": "Type of task. Default: feature.",
835
+ },
836
+ "description": {
837
+ "type": "string",
838
+ "description": "Full, unambiguous description of what to implement or fix. "
839
+ "Include all details gathered from the human.",
840
+ },
841
+ "notify_user_ids": {
842
+ "type": "array",
843
+ "items": {"type": "string"},
844
+ "description": "Extra Slack user IDs to ping on completion (besides the submitter).",
845
+ },
846
+ },
847
+ "required": ["repo_name", "description"],
848
+ },
849
+ },
691
850
  {
692
851
  "name": "list_pending_prs",
693
852
  "description": "List all open Sentinel PRs awaiting admin review.",
@@ -1287,12 +1446,14 @@ _TOOLS = [
1287
1446
  {
1288
1447
  "name": "merge_pr",
1289
1448
  "description": (
1290
- "ADMIN ONLY. Merge a PR into the main branch. "
1291
- "ALWAYS call with confirmed=false first to show PR details for admin review — "
1449
+ "ADMIN ONLY. Merge a branch or PR into the main branch. "
1450
+ "ALWAYS call with confirmed=false first to show details for admin review — "
1292
1451
  "never merge without showing the plan first. "
1293
- "Use for Sentinel fix PRs (AUTO_PUBLISH=false) or Renovate/external PRs by number. "
1294
- "confirmed=false fetches and shows PR details; confirmed=true executes the merge. "
1295
- "e.g. 'merge the fix for Whydah-TypeLib', 'merge TypeLib PR #247'"
1452
+ "Use for Sentinel fix PRs (AUTO_PUBLISH=false), Renovate/external PRs by number, "
1453
+ "or any arbitrary branch by name (branch_name). "
1454
+ "confirmed=false fetches and shows details; confirmed=true executes the merge. "
1455
+ "e.g. 'merge the fix for Whydah-TypeLib', 'merge TypeLib PR #247', "
1456
+ "'merge branch fix/pin-header-geometry-sync into elprint-connector-service'"
1296
1457
  ),
1297
1458
  "input_schema": {
1298
1459
  "type": "object",
@@ -1310,10 +1471,16 @@ _TOOLS = [
1310
1471
  "description": "Merge a specific PR by number (e.g. a Renovate PR). "
1311
1472
  "When set, repo_name is still required but fingerprint is ignored.",
1312
1473
  },
1474
+ "branch_name": {
1475
+ "type": "string",
1476
+ "description": "Merge an arbitrary branch into the repo's main branch via "
1477
+ "GitHub Merge API. Use when the user gives a branch URL or name "
1478
+ "without an associated PR. e.g. 'fix/pin-header-geometry-sync'",
1479
+ },
1313
1480
  "confirmed": {
1314
1481
  "type": "boolean",
1315
1482
  "description": (
1316
- "false (default) = fetch PR details and show plan for review, do NOT merge yet. "
1483
+ "false (default) = fetch details and show plan for review, do NOT merge yet. "
1317
1484
  "true = execute the merge after admin has seen and approved the plan."
1318
1485
  ),
1319
1486
  },
@@ -1925,6 +2092,118 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1925
2092
  })
1926
2093
 
1927
2094
 
2095
+ if name == "dev_task":
2096
+ if not is_admin:
2097
+ return json.dumps({"error": "Only admins can submit dev tasks to Patch."})
2098
+
2099
+ description = inputs.get("description", "").strip()
2100
+ if not description:
2101
+ return json.dumps({"error": "description is required"})
2102
+ task_type = inputs.get("task_type", "feature").strip()
2103
+ if task_type not in ("feature", "fix", "refactor", "chore", "ask"):
2104
+ task_type = "feature"
2105
+
2106
+ # Drop the task into the dev-tasks/ directory of the project dir.
2107
+ # Use the sentinel-1881/sentinel-elprint instance directory (parent of config/).
2108
+ # Boss runs from a workspace instance — use the first project dir that has a config.
2109
+ _project_dirs = _find_project_dirs()
2110
+ if not _project_dirs:
2111
+ return json.dumps({"error": "No project directory found — cannot drop dev task."})
2112
+ _dev_project_dir = _project_dirs[0]
2113
+
2114
+ from .sentinel_dev import drop_escalation as _drop_task
2115
+ from datetime import datetime as _dt, timezone as _tz
2116
+ import uuid as _uuid
2117
+
2118
+ dev_tasks_dir = _dev_project_dir / "dev-tasks"
2119
+ dev_tasks_dir.mkdir(exist_ok=True)
2120
+ ts = int(__import__("time").time())
2121
+ fname = f"slack-{_uuid.uuid4().hex[:8]}-{ts}.txt"
2122
+ fpath = dev_tasks_dir / fname
2123
+
2124
+ # Collect notify list: extra users requested + deduplicate
2125
+ notify_ids = inputs.get("notify_user_ids") or []
2126
+ if isinstance(notify_ids, list):
2127
+ notify_ids = [u for u in notify_ids if u and u != user_id]
2128
+
2129
+ lines = [
2130
+ f"TYPE: {task_type}",
2131
+ f"SUBMITTED_BY: <@{user_id}> ({user_id})",
2132
+ f"SOURCE: boss",
2133
+ f"SUBMITTED_AT: {_dt.now(_tz.utc).isoformat()}",
2134
+ ]
2135
+ if notify_ids:
2136
+ lines.append(f"NOTIFY: {','.join(notify_ids)}")
2137
+ lines += ["", description]
2138
+ fpath.write_text("\n".join(lines), encoding="utf-8")
2139
+ logger.info("Boss dev_task: dropped %s for user %s (type=%s)", fname, user_id, task_type)
2140
+
2141
+ project_label = _read_project_name(_dev_project_dir.resolve())
2142
+ return json.dumps({
2143
+ "status": "queued",
2144
+ "project": project_label,
2145
+ "file": fname,
2146
+ "task_type": task_type,
2147
+ "note": (
2148
+ "Dev task queued — Patch will pick it up on the next poll cycle "
2149
+ "and post progress to this channel."
2150
+ ),
2151
+ })
2152
+
2153
+
2154
+ if name == "repo_task":
2155
+ if not is_admin:
2156
+ return json.dumps({"error": "Only admins can submit repo tasks."})
2157
+
2158
+ repo_name = inputs.get("repo_name", "").strip()
2159
+ description = inputs.get("description", "").strip()
2160
+ if not repo_name:
2161
+ return json.dumps({"error": "repo_name is required"})
2162
+ if not description:
2163
+ return json.dumps({"error": "description is required"})
2164
+ task_type = inputs.get("task_type", "feature").strip()
2165
+ if task_type not in ("feature", "fix", "refactor", "chore"):
2166
+ task_type = "feature"
2167
+
2168
+ # Fuzzy-match repo name
2169
+ if repo_name not in cfg_loader.repos:
2170
+ for rname in cfg_loader.repos:
2171
+ if repo_name.lower() in rname.lower() or rname.lower() in repo_name.lower():
2172
+ repo_name = rname
2173
+ break
2174
+ if repo_name not in cfg_loader.repos:
2175
+ return json.dumps({
2176
+ "error": f"No repo matching '{repo_name}' found.",
2177
+ "available_repos": list(cfg_loader.repos.keys()),
2178
+ })
2179
+
2180
+ notify_ids = inputs.get("notify_user_ids") or []
2181
+ if isinstance(notify_ids, list):
2182
+ notify_ids = [u for u in notify_ids if u and u != user_id]
2183
+
2184
+ _project_dirs = _find_project_dirs()
2185
+ if not _project_dirs:
2186
+ return json.dumps({"error": "No project directory found."})
2187
+
2188
+ from .repo_task_engine import drop_repo_task as _drop_repo_task
2189
+ task_file = _drop_repo_task(
2190
+ _project_dirs[0],
2191
+ repo_name=repo_name,
2192
+ task_type=task_type,
2193
+ description=description,
2194
+ submitter_user_id=user_id,
2195
+ notify_user_ids=notify_ids,
2196
+ )
2197
+ logger.info("Boss repo_task: dropped %s for user %s (repo=%s)", task_file.name, user_id, repo_name)
2198
+ return json.dumps({
2199
+ "status": "queued",
2200
+ "repo": repo_name,
2201
+ "task_type": task_type,
2202
+ "file": task_file.name,
2203
+ "note": f"Task queued for `{repo_name}` — Claude will implement and post progress here.",
2204
+ })
2205
+
2206
+
1928
2207
  if name == "get_fix_details":
1929
2208
  fp = inputs["fingerprint"]
1930
2209
  fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
@@ -3139,6 +3418,92 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
3139
3418
  repo_name = rname
3140
3419
  break
3141
3420
 
3421
+ branch_name = inputs.get("branch_name", "").strip()
3422
+
3423
+ # ── Path C: branch_name — merge via GitHub Merge API (no PR needed) ─
3424
+ if branch_name and not pr_number_in:
3425
+ repo_cfg = cfg_loader.repos.get(repo_name)
3426
+ owner_repo = ""
3427
+ if repo_cfg and repo_cfg.repo_url:
3428
+ url = repo_cfg.repo_url
3429
+ owner_repo = url.split(":")[-1].removesuffix(".git") if url.startswith("git@") \
3430
+ else "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
3431
+ if not owner_repo:
3432
+ return json.dumps({"error": f"Cannot determine GitHub owner/repo for '{repo_name}'"})
3433
+
3434
+ base_branch = (repo_cfg.branch if repo_cfg else None) or "main"
3435
+
3436
+ # Fetch branch info to confirm it exists and get latest commit
3437
+ branch_resp = _req.get(
3438
+ f"https://api.github.com/repos/{owner_repo}/branches/{branch_name}",
3439
+ headers=headers, timeout=15,
3440
+ )
3441
+ if branch_resp.status_code == 404:
3442
+ return json.dumps({"error": f"Branch '{branch_name}' not found in {owner_repo}"})
3443
+ if branch_resp.status_code != 200:
3444
+ return json.dumps({"error": f"GitHub API error ({branch_resp.status_code}): {branch_resp.text[:200]}"})
3445
+ branch_data = branch_resp.json()
3446
+ head_sha = branch_data.get("commit", {}).get("sha", "")[:8]
3447
+ head_msg = branch_data.get("commit", {}).get("commit", {}).get("message", "").splitlines()[0]
3448
+
3449
+ # Compare branch vs base to get ahead/behind counts
3450
+ compare_resp = _req.get(
3451
+ f"https://api.github.com/repos/{owner_repo}/compare/{base_branch}...{branch_name}",
3452
+ headers=headers, timeout=15,
3453
+ )
3454
+ compare_data = compare_resp.json() if compare_resp.status_code == 200 else {}
3455
+ ahead_by = compare_data.get("ahead_by", "?")
3456
+ behind_by = compare_data.get("behind_by", "?")
3457
+ files_list = [f.get("filename", "") for f in compare_data.get("files", [])]
3458
+
3459
+ if not confirmed:
3460
+ return json.dumps({
3461
+ "plan": f"Merge branch '{branch_name}' into {repo_name}/{base_branch}",
3462
+ "branch": branch_name,
3463
+ "base": base_branch,
3464
+ "head_sha": head_sha,
3465
+ "head_commit": head_msg,
3466
+ "ahead_by": ahead_by,
3467
+ "behind_by": behind_by,
3468
+ "files": files_list,
3469
+ "confirm_prompt": (
3470
+ f"This will merge branch '{branch_name}' (ahead by {ahead_by} commit(s)) "
3471
+ f"into {repo_name}/{base_branch} via GitHub Merge API. "
3472
+ f"Reply with confirmed=true to proceed."
3473
+ ),
3474
+ })
3475
+
3476
+ merge_resp = _req.post(
3477
+ f"https://api.github.com/repos/{owner_repo}/merges",
3478
+ json={"base": base_branch, "head": branch_name,
3479
+ "commit_message": f"chore: merge branch '{branch_name}' into {base_branch}"},
3480
+ headers=headers, timeout=30,
3481
+ )
3482
+ if merge_resp.status_code == 201:
3483
+ sha = merge_resp.json().get("sha", "")[:8]
3484
+ logger.info("Boss merge_pr (branch): merged '%s' into %s/%s by %s sha=%s",
3485
+ branch_name, repo_name, base_branch, user_id, sha)
3486
+ return json.dumps({
3487
+ "status": "merged",
3488
+ "branch": branch_name,
3489
+ "base": base_branch,
3490
+ "sha": sha,
3491
+ "repo": repo_name,
3492
+ "note": f"Branch '{branch_name}' merged into {base_branch}. SHA: {sha}",
3493
+ })
3494
+ if merge_resp.status_code == 204:
3495
+ return json.dumps({
3496
+ "status": "already_merged",
3497
+ "note": f"Branch '{branch_name}' is already fully merged into {base_branch}.",
3498
+ })
3499
+ if merge_resp.status_code == 409:
3500
+ return json.dumps({
3501
+ "status": "conflict",
3502
+ "error": f"Merge conflict between '{branch_name}' and '{base_branch}'. Resolve manually on GitHub.",
3503
+ })
3504
+ return json.dumps({"status": "error",
3505
+ "error": f"GitHub API returned {merge_resp.status_code}: {merge_resp.text[:300]}"})
3506
+
3142
3507
  # ── Path A: explicit pr_number — fetch details, then merge ────────
3143
3508
  if pr_number_in:
3144
3509
  pr_number_in = int(pr_number_in)
@@ -3169,6 +3534,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
3169
3534
  )
3170
3535
  if pr_resp.status_code == 404:
3171
3536
  return json.dumps({"error": f"PR #{pr_number_in} not found in {owner_repo}"})
3537
+ if pr_resp.status_code in (401, 403):
3538
+ return json.dumps({"error": _GITHUB_TOKEN_403_GUIDE})
3172
3539
  if pr_resp.status_code != 200:
3173
3540
  return json.dumps({"error": f"GitHub API error ({pr_resp.status_code}): {pr_resp.text[:200]}"})
3174
3541
  pr_data = pr_resp.json()