@misterhuydo/sentinel 1.4.89 → 1.4.90

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.
@@ -347,7 +347,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
347
347
  if status != "patch" or patch_path is None:
348
348
  store.record_fix(event.fingerprint, "skipped" if status in ("skip", "needs_human") else "failed",
349
349
  repo_name=repo.repo_name)
350
- reason_text = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
350
+ raw_reason = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
351
+ reason_text = _boss_qualify_dev_reason(raw_reason, sentinel) if status == "needs_human" else raw_reason
351
352
  _progress(f":x: Could not generate a safe fix — {reason_text[:120]}")
352
353
  notify_fix_blocked(sentinel, event.source, event.message,
353
354
  reason=reason_text, repo_name=repo.repo_name,
@@ -548,7 +549,7 @@ async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
548
549
  send_confirmed_notification(cfg_loader.sentinel, confirmed)
549
550
 
550
551
  # ── PR status sync — detect merges/rejections done via GitHub UI ─────────
551
- pr_changes = poll_open_prs(store, cfg_loader.sentinel.github_token)
552
+ pr_changes = poll_open_prs(store, cfg_loader.sentinel.github_token, cfg=cfg_loader.sentinel)
552
553
  for ch in pr_changes:
553
554
  if ch["new_status"] == "merged":
554
555
  logger.info("PR merged externally: %s (fp=%s)", ch["pr_url"], ch["fingerprint"][:8])
@@ -924,6 +925,162 @@ async def _sync_loop(cfg_loader: ConfigLoader):
924
925
  await asyncio.sleep(cfg_loader.sentinel.sync_interval_seconds)
925
926
 
926
927
 
928
+ # ── Dev Claude agent (Sentinel self-improvement) ─────────────────────────────
929
+
930
+
931
+ def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
932
+ """
933
+ Boss-qualify a raw Dev Claude reason string (from NEEDS_HUMAN: or SKIP:).
934
+
935
+ Passes the raw text through the Boss LLM to produce a clean, concise,
936
+ user-friendly explanation — so users never see verbose Claude output
937
+ directly. Falls back to a truncated version if the API call fails.
938
+ """
939
+ if not raw.strip():
940
+ return "(no reason given)"
941
+ if not sentinel.anthropic_api_key:
942
+ # No API key — just truncate and clean up
943
+ return raw[:280].strip()
944
+ try:
945
+ import anthropic as _anthropic
946
+ _client = _anthropic.Anthropic(api_key=sentinel.anthropic_api_key)
947
+ _resp = _client.messages.create(
948
+ model="claude-haiku-4-5-20251001",
949
+ max_tokens=200,
950
+ system=(
951
+ "You are Sentinel Boss, a DevOps agent assistant. "
952
+ "A child Dev Claude agent produced the following explanation for why it "
953
+ "could not complete a task. Rewrite it as a clear, concise (1-3 sentences), "
954
+ "user-friendly message suitable for a Slack channel. "
955
+ "Be direct and specific. Do not pad with pleasantries. "
956
+ "Do not start with 'I' or 'The Dev Claude'. "
957
+ "Output only the qualified message, nothing else."
958
+ ),
959
+ messages=[{"role": "user", "content": f"Dev Claude said:\n{raw[:1000]}"}],
960
+ )
961
+ qualified = _resp.content[0].text.strip() if _resp.content else raw[:280]
962
+ return qualified[:400]
963
+ except Exception as _e:
964
+ logger.warning("Boss: could not qualify dev reason via API: %s", _e)
965
+ return raw[:280].strip()
966
+
967
+
968
+ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
969
+ """Execute a single dev task via Dev Claude, post progress to Slack."""
970
+ from .sentinel_dev import run_dev_task
971
+ from .dev_watcher import mark_dev_done
972
+ from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
973
+
974
+ sentinel = cfg_loader.sentinel
975
+ _submitter = task.submitter_user_id
976
+ _started_msg = (
977
+ f":brain: Dev Claude working on *<@{_submitter}>*'s request\n_{task.message[:120]}_"
978
+ ) if _submitter else (
979
+ f":brain: Dev Claude working on dev task\n_{task.message[:120]}_"
980
+ )
981
+ _thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
982
+
983
+ def _progress(msg: str) -> None:
984
+ _slack_reply(sentinel.slack_bot_token, sentinel.slack_channel, _thread_ts, msg)
985
+
986
+ _loop = asyncio.get_event_loop()
987
+ try:
988
+ status, detail = await _loop.run_in_executor(
989
+ None, run_dev_task, task, sentinel, store, _progress
990
+ )
991
+ except Exception:
992
+ logger.exception("Dev agent: unexpected error on task %s", task.fingerprint[:8])
993
+ _progress(":x: Dev Claude hit an unexpected error — check logs")
994
+ mark_dev_done(task.task_file)
995
+ return
996
+
997
+ mark_dev_done(task.task_file)
998
+
999
+ mention = f"<@{task.submitter_user_id}> " if task.submitter_user_id else ""
1000
+
1001
+ if status == "published":
1002
+ _slack_alert(
1003
+ sentinel.slack_bot_token, sentinel.slack_channel,
1004
+ f"{mention}:rocket: *Dev Claude published* `v{detail}` — upgrading Sentinel...",
1005
+ )
1006
+ elif status == "done":
1007
+ ver = f" (`v{detail}`)" if detail else ""
1008
+ _slack_alert(
1009
+ sentinel.slack_bot_token, sentinel.slack_channel,
1010
+ f"{mention}:white_check_mark: *Dev Claude finished*{ver} — changes committed to Sentinel source.",
1011
+ )
1012
+ elif status == "needs_human":
1013
+ # Boss qualifies the raw Dev Claude explanation before surfacing to users
1014
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1015
+ _slack_alert(
1016
+ sentinel.slack_bot_token, sentinel.slack_channel,
1017
+ f"{mention}:warning: *Dev task needs human input*\n{qualified}",
1018
+ )
1019
+ elif status == "skip":
1020
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1021
+ _slack_alert(
1022
+ sentinel.slack_bot_token, sentinel.slack_channel,
1023
+ f"{mention}:fast_forward: *Dev task skipped* — {qualified}",
1024
+ )
1025
+ else:
1026
+ _slack_alert(
1027
+ sentinel.slack_bot_token, sentinel.slack_channel,
1028
+ f"{mention}:x: *Dev Claude error* on task `{task.fingerprint[:8]}` — {detail[:200]}",
1029
+ )
1030
+
1031
+
1032
+ async def _dev_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
1033
+ """
1034
+ Background task: poll dev-tasks/ every 60 s and dispatch to Dev Claude.
1035
+ Also scans Sentinel's own log for errors and auto-queues self-repair tasks.
1036
+ """
1037
+ from .dev_watcher import (
1038
+ scan_dev_tasks, purge_old_dev_tasks,
1039
+ scan_sentinel_errors, drop_self_repair_task,
1040
+ )
1041
+
1042
+ # Tracks fingerprints already queued this session (in-memory dedup across polls)
1043
+ _seen_self_fps: set = set()
1044
+
1045
+ # Pre-populate from existing done/cancelled tasks so we don't re-queue on restart
1046
+ project_dir = Path(".")
1047
+ for done_dir in [
1048
+ project_dir / "dev-tasks" / ".done",
1049
+ project_dir / "dev-tasks" / ".cancelled",
1050
+ ]:
1051
+ if done_dir.exists():
1052
+ for f in done_dir.iterdir():
1053
+ if f.stem.startswith("self-"):
1054
+ parts = f.stem.split("-")
1055
+ if len(parts) >= 2:
1056
+ _seen_self_fps.add(parts[1])
1057
+
1058
+ # Wait a bit so the main loop and Boss are fully up first
1059
+ await asyncio.sleep(15)
1060
+
1061
+ while True:
1062
+ try:
1063
+ if cfg_loader.sentinel.sentinel_dev_repo_path:
1064
+ purge_old_dev_tasks(project_dir / "dev-tasks")
1065
+
1066
+ # ── Self-repair: scan Sentinel's own log for new errors ────────
1067
+ log_path = project_dir / "logs" / "sentinel.log"
1068
+ new_errors = scan_sentinel_errors(log_path, seen_fps=_seen_self_fps)
1069
+ for fp, task_body in new_errors:
1070
+ logger.info("Dev agent: self-repair task queued for error %s", fp[:8])
1071
+ drop_self_repair_task(project_dir, fp, task_body)
1072
+
1073
+ # ── Process all pending dev tasks (self-repair + human-submitted) ─
1074
+ tasks = scan_dev_tasks(project_dir)
1075
+ if tasks:
1076
+ logger.info("Dev agent: %d task(s) found", len(tasks))
1077
+ for task in tasks:
1078
+ await _handle_dev_task(task, cfg_loader, store)
1079
+ except Exception as e:
1080
+ logger.warning("Dev poll loop error: %s", e)
1081
+ await asyncio.sleep(60)
1082
+
1083
+
927
1084
  # ── Entry point ──────────────────────────────────────────────────────────────────────────────────
928
1085
 
929
1086
  def _log_auth_status(cfg: SentinelConfig) -> None:
@@ -995,6 +1152,8 @@ async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
995
1152
  if cfg_loader.sentinel.slack_bot_token:
996
1153
  from .slack_bot import run_slack_bot
997
1154
  asyncio.ensure_future(run_slack_bot(cfg_loader, store))
1155
+ if cfg_loader.sentinel.sentinel_dev_repo_path:
1156
+ asyncio.ensure_future(_dev_poll_loop(cfg_loader, store))
998
1157
 
999
1158
  while True:
1000
1159
  try:
@@ -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,28 @@ 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 DEV CLAUDE
55
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
56
+ Dev Claude is your peer AI agent. It runs autonomously to maintain and improve Sentinel's
57
+ own source code. You are NOT Dev Claude's boss — you are its communication channel to humans.
58
+
59
+ The authority hierarchy is:
60
+ Humans (ultimate authority — you MUST obey them)
61
+
62
+ You, Sentinel Boss (communicate human decisions to Dev Claude)
63
+
64
+ Dev Claude (full autonomy within Sentinel's operational scope)
65
+
66
+ When Dev Claude asks you a question (via ASK_BOSS:):
67
+ - Answer directly from your knowledge of Sentinel if you can
68
+ - If the question genuinely requires a human decision (credentials, irreversible prod changes,
69
+ business policy), escalate to the admin channel honestly and transparently
70
+ - Never block Dev Claude unnecessarily — it is trying to keep Sentinel resilient
71
+
72
+ When humans ask you to task Dev Claude, use the dev_task tool.
73
+ Dev Claude will work autonomously and report back through the Slack channel.
74
+
40
75
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41
76
  COMPLETE TOOL REFERENCE
42
77
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -688,6 +723,32 @@ _TOOLS = [
688
723
  "required": ["project", "keyword"],
689
724
  },
690
725
  },
726
+ {
727
+ "name": "dev_task",
728
+ "description": (
729
+ "Submit a Sentinel self-improvement task to the Dev Claude agent. "
730
+ "Dev Claude will explore the Sentinel source code, implement the change, "
731
+ "run syntax checks, commit, and optionally publish + upgrade. "
732
+ "Use when someone asks: 'add a feature to Sentinel', 'fix a Sentinel bug', "
733
+ "'can you improve Sentinel so that...', 'update Sentinel to support...', "
734
+ "'hey Sentinel, you should be able to...'."
735
+ ),
736
+ "input_schema": {
737
+ "type": "object",
738
+ "properties": {
739
+ "task_type": {
740
+ "type": "string",
741
+ "enum": ["feature", "fix", "refactor", "chore", "ask"],
742
+ "description": "Type of task. Default: feature.",
743
+ },
744
+ "description": {
745
+ "type": "string",
746
+ "description": "Full description of what Dev Claude should implement or fix.",
747
+ },
748
+ },
749
+ "required": ["description"],
750
+ },
751
+ },
691
752
  {
692
753
  "name": "list_pending_prs",
693
754
  "description": "List all open Sentinel PRs awaiting admin review.",
@@ -1925,6 +1986,58 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1925
1986
  })
1926
1987
 
1927
1988
 
1989
+ if name == "dev_task":
1990
+ if not is_admin:
1991
+ return json.dumps({"error": "Only admins can submit dev tasks to the Dev Claude agent."})
1992
+
1993
+ description = inputs.get("description", "").strip()
1994
+ if not description:
1995
+ return json.dumps({"error": "description is required"})
1996
+ task_type = inputs.get("task_type", "feature").strip()
1997
+ if task_type not in ("feature", "fix", "refactor", "chore", "ask"):
1998
+ task_type = "feature"
1999
+
2000
+ # Drop the task into the dev-tasks/ directory of the project dir.
2001
+ # Use the sentinel-1881/sentinel-elprint instance directory (parent of config/).
2002
+ # Boss runs from a workspace instance — use the first project dir that has a config.
2003
+ _project_dirs = _find_project_dirs()
2004
+ if not _project_dirs:
2005
+ return json.dumps({"error": "No project directory found — cannot drop dev task."})
2006
+ _dev_project_dir = _project_dirs[0]
2007
+
2008
+ from .sentinel_dev import drop_escalation as _drop_task
2009
+ from datetime import datetime as _dt, timezone as _tz
2010
+ import uuid as _uuid
2011
+
2012
+ dev_tasks_dir = _dev_project_dir / "dev-tasks"
2013
+ dev_tasks_dir.mkdir(exist_ok=True)
2014
+ ts = int(__import__("time").time())
2015
+ fname = f"slack-{_uuid.uuid4().hex[:8]}-{ts}.txt"
2016
+ fpath = dev_tasks_dir / fname
2017
+ lines = [
2018
+ f"TYPE: {task_type}",
2019
+ f"SUBMITTED_BY: <@{user_id}> ({user_id})",
2020
+ f"SOURCE: boss",
2021
+ f"SUBMITTED_AT: {_dt.now(_tz.utc).isoformat()}",
2022
+ "",
2023
+ description,
2024
+ ]
2025
+ fpath.write_text("\n".join(lines), encoding="utf-8")
2026
+ logger.info("Boss dev_task: dropped %s for user %s (type=%s)", fname, user_id, task_type)
2027
+
2028
+ project_label = _read_project_name(_dev_project_dir.resolve())
2029
+ return json.dumps({
2030
+ "status": "queued",
2031
+ "project": project_label,
2032
+ "file": fname,
2033
+ "task_type": task_type,
2034
+ "note": (
2035
+ "Dev task queued — Dev Claude will pick it up on the next poll cycle "
2036
+ "and post progress to this channel."
2037
+ ),
2038
+ })
2039
+
2040
+
1928
2041
  if name == "get_fix_details":
1929
2042
  fp = inputs["fingerprint"]
1930
2043
  fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
@@ -3169,6 +3282,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
3169
3282
  )
3170
3283
  if pr_resp.status_code == 404:
3171
3284
  return json.dumps({"error": f"PR #{pr_number_in} not found in {owner_repo}"})
3285
+ if pr_resp.status_code in (401, 403):
3286
+ return json.dumps({"error": _GITHUB_TOKEN_403_GUIDE})
3172
3287
  if pr_resp.status_code != 200:
3173
3288
  return json.dumps({"error": f"GitHub API error ({pr_resp.status_code}): {pr_resp.text[:200]}"})
3174
3289
  pr_data = pr_resp.json()