@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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/lib/.cairn/minify-map.json +8 -1
- package/lib/.cairn/views/ff8fde_test.js +172 -0
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +4 -0
- package/python/sentinel/dev_watcher.py +280 -0
- package/python/sentinel/fix_engine.py +25 -0
- package/python/sentinel/git_manager.py +51 -1
- package/python/sentinel/main.py +161 -2
- package/python/sentinel/sentinel_boss.py +115 -0
- package/python/sentinel/sentinel_dev.py +490 -0
- package/python/sentinel/state_store.py +121 -0
package/python/sentinel/main.py
CHANGED
|
@@ -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
|
-
|
|
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()
|