@misterhuydo/sentinel 1.4.90 → 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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/lib/add.js +8 -20
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +10 -6
- package/python/sentinel/dev_watcher.py +9 -1
- package/python/sentinel/fix_engine.py +1 -1
- package/python/sentinel/main.py +131 -24
- package/python/sentinel/repo_task_engine.py +381 -0
- package/python/sentinel/sentinel_boss.py +275 -23
- package/python/sentinel/sentinel_dev.py +30 -72
- package/templates/log-configs/_example.properties +21 -32
- package/templates/sentinel.properties +5 -6
|
@@ -51,26 +51,79 @@ Your job:
|
|
|
51
51
|
- Answer any question about how Sentinel works, how to configure it, or how to use it
|
|
52
52
|
|
|
53
53
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
54
|
-
YOUR RELATIONSHIP WITH
|
|
54
|
+
YOUR RELATIONSHIP WITH PATCH
|
|
55
55
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
56
|
-
|
|
57
|
-
own source code. You are NOT
|
|
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.
|
|
58
59
|
|
|
59
60
|
The authority hierarchy is:
|
|
60
61
|
Humans (ultimate authority — you MUST obey them)
|
|
61
62
|
↕
|
|
62
|
-
You, Sentinel Boss (
|
|
63
|
+
You, Sentinel Boss (qualify all outputs, relay decisions to/from Patch)
|
|
63
64
|
↕
|
|
64
|
-
|
|
65
|
+
Patch (full autonomy within Sentinel's operational scope)
|
|
65
66
|
|
|
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:):
|
|
67
71
|
- Answer directly from your knowledge of Sentinel if you can
|
|
68
72
|
- If the question genuinely requires a human decision (credentials, irreversible prod changes,
|
|
69
73
|
business policy), escalate to the admin channel honestly and transparently
|
|
70
|
-
- Never block
|
|
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
|
|
71
95
|
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
74
127
|
|
|
75
128
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
76
129
|
COMPLETE TOOL REFERENCE
|
|
@@ -726,9 +779,9 @@ _TOOLS = [
|
|
|
726
779
|
{
|
|
727
780
|
"name": "dev_task",
|
|
728
781
|
"description": (
|
|
729
|
-
"Submit a Sentinel self-improvement task to the
|
|
730
|
-
"
|
|
731
|
-
"run syntax checks, commit
|
|
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. "
|
|
732
785
|
"Use when someone asks: 'add a feature to Sentinel', 'fix a Sentinel bug', "
|
|
733
786
|
"'can you improve Sentinel so that...', 'update Sentinel to support...', "
|
|
734
787
|
"'hey Sentinel, you should be able to...'."
|
|
@@ -743,12 +796,57 @@ _TOOLS = [
|
|
|
743
796
|
},
|
|
744
797
|
"description": {
|
|
745
798
|
"type": "string",
|
|
746
|
-
"description": "Full description of what
|
|
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
|
+
),
|
|
747
809
|
},
|
|
748
810
|
},
|
|
749
811
|
"required": ["description"],
|
|
750
812
|
},
|
|
751
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
|
+
},
|
|
752
850
|
{
|
|
753
851
|
"name": "list_pending_prs",
|
|
754
852
|
"description": "List all open Sentinel PRs awaiting admin review.",
|
|
@@ -1348,12 +1446,14 @@ _TOOLS = [
|
|
|
1348
1446
|
{
|
|
1349
1447
|
"name": "merge_pr",
|
|
1350
1448
|
"description": (
|
|
1351
|
-
"ADMIN ONLY. Merge a PR into the main branch. "
|
|
1352
|
-
"ALWAYS call with confirmed=false first to show
|
|
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 — "
|
|
1353
1451
|
"never merge without showing the plan first. "
|
|
1354
|
-
"Use for Sentinel fix PRs (AUTO_PUBLISH=false)
|
|
1355
|
-
"
|
|
1356
|
-
"
|
|
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'"
|
|
1357
1457
|
),
|
|
1358
1458
|
"input_schema": {
|
|
1359
1459
|
"type": "object",
|
|
@@ -1371,10 +1471,16 @@ _TOOLS = [
|
|
|
1371
1471
|
"description": "Merge a specific PR by number (e.g. a Renovate PR). "
|
|
1372
1472
|
"When set, repo_name is still required but fingerprint is ignored.",
|
|
1373
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
|
+
},
|
|
1374
1480
|
"confirmed": {
|
|
1375
1481
|
"type": "boolean",
|
|
1376
1482
|
"description": (
|
|
1377
|
-
"false (default) = fetch
|
|
1483
|
+
"false (default) = fetch details and show plan for review, do NOT merge yet. "
|
|
1378
1484
|
"true = execute the merge after admin has seen and approved the plan."
|
|
1379
1485
|
),
|
|
1380
1486
|
},
|
|
@@ -1988,7 +2094,7 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1988
2094
|
|
|
1989
2095
|
if name == "dev_task":
|
|
1990
2096
|
if not is_admin:
|
|
1991
|
-
return json.dumps({"error": "Only admins can submit dev tasks to
|
|
2097
|
+
return json.dumps({"error": "Only admins can submit dev tasks to Patch."})
|
|
1992
2098
|
|
|
1993
2099
|
description = inputs.get("description", "").strip()
|
|
1994
2100
|
if not description:
|
|
@@ -2014,14 +2120,21 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2014
2120
|
ts = int(__import__("time").time())
|
|
2015
2121
|
fname = f"slack-{_uuid.uuid4().hex[:8]}-{ts}.txt"
|
|
2016
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
|
+
|
|
2017
2129
|
lines = [
|
|
2018
2130
|
f"TYPE: {task_type}",
|
|
2019
2131
|
f"SUBMITTED_BY: <@{user_id}> ({user_id})",
|
|
2020
2132
|
f"SOURCE: boss",
|
|
2021
2133
|
f"SUBMITTED_AT: {_dt.now(_tz.utc).isoformat()}",
|
|
2022
|
-
"",
|
|
2023
|
-
description,
|
|
2024
2134
|
]
|
|
2135
|
+
if notify_ids:
|
|
2136
|
+
lines.append(f"NOTIFY: {','.join(notify_ids)}")
|
|
2137
|
+
lines += ["", description]
|
|
2025
2138
|
fpath.write_text("\n".join(lines), encoding="utf-8")
|
|
2026
2139
|
logger.info("Boss dev_task: dropped %s for user %s (type=%s)", fname, user_id, task_type)
|
|
2027
2140
|
|
|
@@ -2032,12 +2145,65 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2032
2145
|
"file": fname,
|
|
2033
2146
|
"task_type": task_type,
|
|
2034
2147
|
"note": (
|
|
2035
|
-
"Dev task queued —
|
|
2148
|
+
"Dev task queued — Patch will pick it up on the next poll cycle "
|
|
2036
2149
|
"and post progress to this channel."
|
|
2037
2150
|
),
|
|
2038
2151
|
})
|
|
2039
2152
|
|
|
2040
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
|
+
|
|
2041
2207
|
if name == "get_fix_details":
|
|
2042
2208
|
fp = inputs["fingerprint"]
|
|
2043
2209
|
fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
|
|
@@ -3252,6 +3418,92 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
3252
3418
|
repo_name = rname
|
|
3253
3419
|
break
|
|
3254
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
|
+
|
|
3255
3507
|
# ── Path A: explicit pr_number — fetch details, then merge ────────
|
|
3256
3508
|
if pr_number_in:
|
|
3257
3509
|
pr_number_in = int(pr_number_in)
|
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
"""
|
|
2
|
-
sentinel_dev.py —
|
|
2
|
+
sentinel_dev.py — Patch, the autonomous developer agent for Sentinel self-improvement.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Patch runs alongside Boss but independently. It watches dev-tasks/ for
|
|
5
5
|
requests to improve Sentinel itself — new features, bug fixes, refactors — and
|
|
6
6
|
executes them by running Claude Code against the Sentinel source repository.
|
|
7
7
|
|
|
8
|
+
Patch is an internal actor — humans never interact with it directly. All communication
|
|
9
|
+
goes through Boss, who qualifies Patch's outputs before surfacing anything to users.
|
|
10
|
+
|
|
8
11
|
Invocation sources:
|
|
9
12
|
- Boss (dev_task tool) → slack-<uuid>.txt in dev-tasks/
|
|
10
13
|
- Fix engine (BOSS_ESCALATE output) → bot-<fp>-<ts>.txt in dev-tasks/
|
|
14
|
+
- Self-repair (log watcher) → self-<fp>-<ts>.txt in dev-tasks/
|
|
11
15
|
- Admin (manual file drop) → any .txt in dev-tasks/
|
|
12
16
|
|
|
13
17
|
After a successful task:
|
|
14
18
|
- Commits changes to the Sentinel source repo
|
|
15
|
-
-
|
|
16
|
-
-
|
|
19
|
+
- Changes are live immediately (Sentinel loads Python directly from source repo)
|
|
20
|
+
- Human reviews git log periodically → bumps version → npm publish → auto-upgrade distributes
|
|
17
21
|
"""
|
|
18
22
|
from __future__ import annotations
|
|
19
23
|
|
|
20
24
|
import logging
|
|
21
25
|
import os
|
|
22
26
|
import re
|
|
23
|
-
import subprocess
|
|
24
27
|
import time
|
|
25
28
|
|
|
26
29
|
from datetime import datetime, timezone
|
|
@@ -33,6 +36,9 @@ from .notify import slack_alert
|
|
|
33
36
|
|
|
34
37
|
logger = logging.getLogger(__name__)
|
|
35
38
|
|
|
39
|
+
# Internal name for the dev agent — used in logs and inter-agent communication
|
|
40
|
+
PATCH_NAME = "Patch"
|
|
41
|
+
|
|
36
42
|
# Dev tasks are bigger than fix tasks — allow 15 minutes
|
|
37
43
|
_DEV_TIMEOUT = 900
|
|
38
44
|
|
|
@@ -68,7 +74,7 @@ def _build_dev_prompt(task: DevTask, repo_path: str, past_outcomes: list | None
|
|
|
68
74
|
memory_ctx = "\n".join(lines) + "\n\n"
|
|
69
75
|
|
|
70
76
|
return (
|
|
71
|
-
f"You are
|
|
77
|
+
f"You are Patch — a fully autonomous AI developer responsible for the\n"
|
|
72
78
|
f"resiliency, stability, and continuous improvement of the Sentinel DevOps system.\n"
|
|
73
79
|
f"Sentinel source repository: {repo_path}\n"
|
|
74
80
|
f"{submitted}\n"
|
|
@@ -89,9 +95,10 @@ def _build_dev_prompt(task: DevTask, repo_path: str, past_outcomes: list | None
|
|
|
89
95
|
f"You do NOT need permission for any of the above. Act, don't ask.\n"
|
|
90
96
|
f"\n"
|
|
91
97
|
f"You work WITH Sentinel Boss, not under it. The relationship:\n"
|
|
92
|
-
f" Humans → Boss (Boss obeys humans)\n"
|
|
93
|
-
f" Boss ↔
|
|
94
|
-
f"
|
|
98
|
+
f" Humans → Boss (Boss obeys humans, surfaces your work to humans)\n"
|
|
99
|
+
f" Boss ↔ Patch (peer agents — you can ask Boss, Boss can task you)\n"
|
|
100
|
+
f" Patch → Sentinel (full autonomy over Sentinel's codebase and all instances)\n"
|
|
101
|
+
f"Humans never interact with you directly — Boss is their only interface.\n"
|
|
95
102
|
f"\n"
|
|
96
103
|
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
97
104
|
f"SENTINEL CODEBASE OVERVIEW\n"
|
|
@@ -105,7 +112,7 @@ def _build_dev_prompt(task: DevTask, repo_path: str, past_outcomes: list | None
|
|
|
105
112
|
f"- Log parsing: sentinel/log_parser.py\n"
|
|
106
113
|
f"- Issue queue: sentinel/issue_watcher.py\n"
|
|
107
114
|
f"- Dev task queue + self-repair: sentinel/dev_watcher.py\n"
|
|
108
|
-
f"-
|
|
115
|
+
f"- Patch (you): sentinel/sentinel_dev.py\n"
|
|
109
116
|
f"- State + memory: sentinel/state_store.py (SQLite, incl. dev_history)\n"
|
|
110
117
|
f"- Notifications: sentinel/notify.py\n"
|
|
111
118
|
f"- CLI package: cli/ (Node.js, npm package @misterhuydo/sentinel)\n"
|
|
@@ -127,10 +134,9 @@ def _build_dev_prompt(task: DevTask, repo_path: str, past_outcomes: list | None
|
|
|
127
134
|
f"5. Commit all changes:\n"
|
|
128
135
|
f" git add -A -- sentinel/ cli/ (or be more selective)\n"
|
|
129
136
|
f" git commit -m \"{task.task_type}(dev-agent): <concise summary> [sentinel-dev]\"\n"
|
|
130
|
-
f"6.
|
|
131
|
-
f"
|
|
132
|
-
f"
|
|
133
|
-
f"7. End your response with a brief summary of what changed (max 10 lines).\n"
|
|
137
|
+
f"6. End your response with a brief summary of what changed (max 10 lines).\n"
|
|
138
|
+
f" Your changes are live immediately — Sentinel loads Python directly from this repo.\n"
|
|
139
|
+
f" A human will review your commits and publish to npm when ready.\n"
|
|
134
140
|
f"\n"
|
|
135
141
|
f"BOUNDARIES (the only things outside your scope):\n"
|
|
136
142
|
f"- Never touch managed application repos (the repos Sentinel monitors — not this repo)\n"
|
|
@@ -149,12 +155,12 @@ def _build_dev_prompt(task: DevTask, repo_path: str, past_outcomes: list | None
|
|
|
149
155
|
)
|
|
150
156
|
|
|
151
157
|
|
|
152
|
-
_ASK_BOSS_RETRIES = 2 # how many times
|
|
158
|
+
_ASK_BOSS_RETRIES = 2 # how many times Patch may ask Boss per task
|
|
153
159
|
|
|
154
160
|
|
|
155
161
|
def _consult_boss(question: str, task_context: str, cfg: "SentinelConfig") -> str:
|
|
156
162
|
"""
|
|
157
|
-
Route a
|
|
163
|
+
Route a Patch question to the Boss LLM.
|
|
158
164
|
Boss answers from its knowledge of Sentinel, or indicates it needs human input.
|
|
159
165
|
Returns Boss's answer as a string.
|
|
160
166
|
"""
|
|
@@ -168,17 +174,17 @@ def _consult_boss(question: str, task_context: str, cfg: "SentinelConfig") -> st
|
|
|
168
174
|
max_tokens=600,
|
|
169
175
|
system=(
|
|
170
176
|
"You are Sentinel Boss — the operational orchestrator of the Sentinel DevOps system. "
|
|
171
|
-
"You are answering a question from
|
|
172
|
-
"Sentinel's source code autonomously.
|
|
177
|
+
"You are answering a question from Patch, your peer AI agent who maintains "
|
|
178
|
+
"Sentinel's source code autonomously. Patch has full authority within Sentinel's "
|
|
173
179
|
"operational scope and only asks you when it truly needs information it cannot find itself.\n\n"
|
|
174
180
|
"Answer concisely and directly. If you know the answer from Sentinel's architecture or "
|
|
175
181
|
"standard practices, give it. If the question requires a human admin decision (e.g. "
|
|
176
182
|
"secret credentials, budget approval, irreversible production changes), reply with:\n"
|
|
177
183
|
"NEEDS_HUMAN: <brief reason>\n\n"
|
|
178
|
-
"Context of the task
|
|
184
|
+
"Context of the task Patch is working on:\n"
|
|
179
185
|
f"{task_context[:400]}"
|
|
180
186
|
),
|
|
181
|
-
messages=[{"role": "user", "content": f"
|
|
187
|
+
messages=[{"role": "user", "content": f"Patch asks: {question}"}],
|
|
182
188
|
)
|
|
183
189
|
return _resp.content[0].text.strip() if _resp.content else "(no answer from Boss)"
|
|
184
190
|
except Exception as _e:
|
|
@@ -186,11 +192,6 @@ def _consult_boss(question: str, task_context: str, cfg: "SentinelConfig") -> st
|
|
|
186
192
|
return f"(Boss consultation failed: {_e})"
|
|
187
193
|
|
|
188
194
|
|
|
189
|
-
def _extract_version_bumped(output: str) -> str | None:
|
|
190
|
-
"""Parse VERSION_BUMPED: <version> from Claude output."""
|
|
191
|
-
m = re.search(r'^VERSION_BUMPED:\s*(\S+)', output, re.MULTILINE | re.IGNORECASE)
|
|
192
|
-
return m.group(1) if m else None
|
|
193
|
-
|
|
194
195
|
|
|
195
196
|
def _extract_summary(output: str) -> str:
|
|
196
197
|
"""Extract the last meaningful paragraph from Claude output (not tool-use lines)."""
|
|
@@ -198,40 +199,12 @@ def _extract_summary(output: str) -> str:
|
|
|
198
199
|
substantive = [
|
|
199
200
|
l for l in lines
|
|
200
201
|
if l.strip() and not re.match(r'^[⏺⎆●✦✓✗]', l.strip())
|
|
201
|
-
and not l.strip().startswith("VERSION_BUMPED")
|
|
202
202
|
]
|
|
203
203
|
if not substantive:
|
|
204
204
|
return output[-400:].strip()
|
|
205
205
|
return "\n".join(substantive[-12:])[:500]
|
|
206
206
|
|
|
207
207
|
|
|
208
|
-
def _run_npm_publish(repo_path: str, env: dict, on_progress=None) -> bool:
|
|
209
|
-
"""Run npm publish from cli/ directory. Returns True on success."""
|
|
210
|
-
cli_dir = Path(repo_path) / "cli"
|
|
211
|
-
if not cli_dir.exists():
|
|
212
|
-
logger.warning("Dev agent: cli/ not found at %s", cli_dir)
|
|
213
|
-
return False
|
|
214
|
-
if on_progress:
|
|
215
|
-
try:
|
|
216
|
-
on_progress(":rocket: Publishing to npm...")
|
|
217
|
-
except Exception:
|
|
218
|
-
pass
|
|
219
|
-
try:
|
|
220
|
-
r = subprocess.run(
|
|
221
|
-
["npm", "publish", "--access", "public"],
|
|
222
|
-
cwd=str(cli_dir),
|
|
223
|
-
capture_output=True, text=True, timeout=120, env=env,
|
|
224
|
-
)
|
|
225
|
-
if r.returncode == 0:
|
|
226
|
-
logger.info("Dev agent: npm publish succeeded")
|
|
227
|
-
return True
|
|
228
|
-
logger.error("Dev agent: npm publish failed (rc=%d): %s", r.returncode, r.stderr[:300])
|
|
229
|
-
return False
|
|
230
|
-
except Exception as e:
|
|
231
|
-
logger.error("Dev agent: npm publish error: %s", e)
|
|
232
|
-
return False
|
|
233
|
-
|
|
234
|
-
|
|
235
208
|
|
|
236
209
|
def _dev_progress_from_line(line: str) -> str | None:
|
|
237
210
|
"""Convert Claude Code tool-use lines to human-readable dev progress messages."""
|
|
@@ -370,7 +343,7 @@ def run_dev_task(
|
|
|
370
343
|
|
|
371
344
|
if timed_out:
|
|
372
345
|
logger.error("Dev agent: Claude timed out for task %s", task.fingerprint[:8])
|
|
373
|
-
return "error", "
|
|
346
|
+
return "error", "Patch timed out after 15 minutes."
|
|
374
347
|
|
|
375
348
|
stripped = output.strip()
|
|
376
349
|
|
|
@@ -416,7 +389,7 @@ def run_dev_task(
|
|
|
416
389
|
else:
|
|
417
390
|
# Exhausted retries — treat as needs_human (Boss couldn't unblock Dev Claude)
|
|
418
391
|
_record("needs_human", note="Exhausted Boss consultations without completing task")
|
|
419
|
-
return "needs_human", "
|
|
392
|
+
return "needs_human", "Patch could not complete the task after consulting Boss. Human review needed."
|
|
420
393
|
|
|
421
394
|
if stripped.upper().startswith("SKIP:"):
|
|
422
395
|
reason = stripped[5:].strip()
|
|
@@ -424,30 +397,15 @@ def run_dev_task(
|
|
|
424
397
|
_record("skip", note=reason[:400])
|
|
425
398
|
return "skip", reason
|
|
426
399
|
|
|
427
|
-
# Parse optional VERSION_BUMPED signal and summary
|
|
428
|
-
new_version = _extract_version_bumped(output)
|
|
429
400
|
summary = _extract_summary(output)
|
|
430
|
-
|
|
431
|
-
logger.info(
|
|
432
|
-
"Dev agent: task %s completed (version_bumped=%s)",
|
|
433
|
-
task.fingerprint[:8], new_version or "no",
|
|
434
|
-
)
|
|
401
|
+
logger.info("Dev agent: task %s completed", task.fingerprint[:8])
|
|
435
402
|
|
|
436
403
|
# Extract files changed from git output in Claude's response (best-effort)
|
|
437
404
|
_files_re = re.findall(r'\bsentinel/\S+\.py\b|\bcli/\S+\.(?:js|json)\b', output)
|
|
438
405
|
files_str = ", ".join(dict.fromkeys(_files_re))[:300]
|
|
439
406
|
|
|
440
|
-
# Post-execution: publish to npm if configured and version was bumped
|
|
441
|
-
if cfg.sentinel_dev_auto_publish and new_version:
|
|
442
|
-
published = _run_npm_publish(repo_path, base_env, on_progress)
|
|
443
|
-
if published:
|
|
444
|
-
_record("published", note=summary, files=files_str, commit=new_version)
|
|
445
|
-
return "published", new_version
|
|
446
|
-
_record("done", note=summary, files=files_str, commit=new_version)
|
|
447
|
-
return "done", new_version
|
|
448
|
-
|
|
449
407
|
_record("done", note=summary, files=files_str)
|
|
450
|
-
return "done",
|
|
408
|
+
return "done", ""
|
|
451
409
|
|
|
452
410
|
|
|
453
411
|
def _fire_progress(line: str, on_progress) -> None:
|