@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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
repo_task_engine.py — Run human-requested tasks (features, fixes, refactors)
|
|
3
|
+
against managed repos via Claude Code.
|
|
4
|
+
|
|
5
|
+
Unlike fix_engine.py which handles error-triggered fixes, this handles explicit
|
|
6
|
+
human requests routed through Boss after full requirement gathering.
|
|
7
|
+
|
|
8
|
+
Task files live in <project_dir>/repo-tasks/<repo_name>-<uuid>-<ts>.txt
|
|
9
|
+
|
|
10
|
+
File format:
|
|
11
|
+
REPO: elprint-connector-service
|
|
12
|
+
TYPE: feature|fix|refactor|chore
|
|
13
|
+
SUBMITTED_BY: <@UXXX> (UXXX)
|
|
14
|
+
SUBMITTED_AT: 2026-03-27T10:00:00+00:00
|
|
15
|
+
NOTIFY: U1234567,U7654321 # optional
|
|
16
|
+
|
|
17
|
+
Full task description — what to implement, fix, or refactor.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .config_loader import RepoConfig, SentinelConfig
|
|
31
|
+
from .fix_engine import _claude_cmd, _run_claude_attempt, _is_auth_error, _progress_from_line
|
|
32
|
+
from .git_manager import _git_env
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "NOTIFY:")
|
|
37
|
+
_TASK_TIMEOUT = 900 # 15 minutes
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RepoTask:
|
|
42
|
+
task_file: Path
|
|
43
|
+
repo_name: str
|
|
44
|
+
task_type: str # feature|fix|refactor|chore
|
|
45
|
+
description: str # full task body
|
|
46
|
+
message: str # first non-blank line (shown in Slack)
|
|
47
|
+
submitter_user_id: str = ""
|
|
48
|
+
notify_user_ids: list = field(default_factory=list)
|
|
49
|
+
fingerprint: str = ""
|
|
50
|
+
timestamp: str = ""
|
|
51
|
+
|
|
52
|
+
def __post_init__(self):
|
|
53
|
+
if not self.fingerprint:
|
|
54
|
+
raw = f"repo-task:{self.repo_name}:{self.task_type}:{self.message[:200]}"
|
|
55
|
+
self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
|
|
56
|
+
if not self.timestamp:
|
|
57
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_repo_prompt(task: RepoTask, repo: RepoConfig) -> str:
|
|
61
|
+
submitted = f"Requested by: <@{task.submitter_user_id}>" if task.submitter_user_id else ""
|
|
62
|
+
return (
|
|
63
|
+
f"You are implementing a requested change in the repository at {repo.local_path}.\n"
|
|
64
|
+
f"Repository: {repo.repo_name}\n"
|
|
65
|
+
f"{submitted}\n"
|
|
66
|
+
f"\n"
|
|
67
|
+
f"TASK TYPE: {task.task_type}\n"
|
|
68
|
+
f"TASK:\n{task.description}\n\n"
|
|
69
|
+
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
70
|
+
f"INSTRUCTIONS\n"
|
|
71
|
+
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
72
|
+
f"1. Explore the codebase first — understand structure, patterns, and conventions\n"
|
|
73
|
+
f" before making any changes.\n"
|
|
74
|
+
f"2. Implement the requested change following the repo's existing code style.\n"
|
|
75
|
+
f"3. Syntax/compile check modified files.\n"
|
|
76
|
+
f"4. Run tests if available (Maven, Gradle, npm test) — commit only if passing.\n"
|
|
77
|
+
f"5. Commit all changes:\n"
|
|
78
|
+
f" git add -A\n"
|
|
79
|
+
f" git commit -m \"{task.task_type}(<scope>): <concise summary> [sentinel-task]\"\n"
|
|
80
|
+
f"6. End with a brief summary (max 10 lines) of what changed and why.\n"
|
|
81
|
+
f"\n"
|
|
82
|
+
f"BOUNDARIES:\n"
|
|
83
|
+
f"- Never modify CI/CD config files (.github/, Jenkinsfile, pom.xml build sections)\n"
|
|
84
|
+
f"- Implement exactly what was requested — no extra scope\n"
|
|
85
|
+
f"- If the task requires information you don't have:\n"
|
|
86
|
+
f" output exactly: NEEDS_HUMAN: <what is missing>\n"
|
|
87
|
+
f"- If implementing this requires changing Sentinel itself (not this repo):\n"
|
|
88
|
+
f" output exactly: BOSS_ESCALATE: <description>\n"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_head(local_path: str, env: dict) -> str:
|
|
93
|
+
r = subprocess.run(
|
|
94
|
+
["git", "rev-parse", "HEAD"],
|
|
95
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=10,
|
|
96
|
+
)
|
|
97
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _open_task_pr(repo: RepoConfig, cfg: SentinelConfig, branch: str, task: RepoTask, commit_hash: str) -> str:
|
|
101
|
+
"""Open a GitHub PR for a human-requested task."""
|
|
102
|
+
if not cfg.github_token:
|
|
103
|
+
logger.warning("GITHUB_TOKEN not set — cannot open PR for repo task")
|
|
104
|
+
return ""
|
|
105
|
+
url = repo.repo_url
|
|
106
|
+
owner_repo = (
|
|
107
|
+
url.split(":")[-1].removesuffix(".git") if url.startswith("git@")
|
|
108
|
+
else "/".join(url.rstrip("/").split("/")[-2:]).removesuffix(".git")
|
|
109
|
+
)
|
|
110
|
+
title = f"[Sentinel] {task.task_type}({repo.repo_name}): {task.message[:60]}"
|
|
111
|
+
submitter_line = f"**Requested by:** <@{task.submitter_user_id}>\n" if task.submitter_user_id else ""
|
|
112
|
+
body = (
|
|
113
|
+
f"## Human-requested task via Sentinel Boss\n\n"
|
|
114
|
+
f"**Task type:** `{task.task_type}`\n"
|
|
115
|
+
f"{submitter_line}"
|
|
116
|
+
f"**Commit:** `{commit_hash[:8]}`\n\n"
|
|
117
|
+
f"### Description\n{task.description[:1000]}\n\n"
|
|
118
|
+
f"---\n_Implemented by Sentinel. Review and merge when satisfied._"
|
|
119
|
+
)
|
|
120
|
+
import requests as _req
|
|
121
|
+
resp = _req.post(
|
|
122
|
+
f"https://api.github.com/repos/{owner_repo}/pulls",
|
|
123
|
+
json={"title": title, "body": body, "head": branch, "base": repo.branch},
|
|
124
|
+
headers={"Authorization": f"Bearer {cfg.github_token}", "Accept": "application/vnd.github+json"},
|
|
125
|
+
timeout=30,
|
|
126
|
+
)
|
|
127
|
+
if resp.status_code == 201:
|
|
128
|
+
pr_url = resp.json().get("html_url", "")
|
|
129
|
+
logger.info("Task PR opened: %s", pr_url)
|
|
130
|
+
return pr_url
|
|
131
|
+
logger.error("Failed to open task PR (%s): %s", resp.status_code, resp.text[:300])
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_repo_task(
|
|
136
|
+
task: RepoTask,
|
|
137
|
+
repo: RepoConfig,
|
|
138
|
+
cfg: SentinelConfig,
|
|
139
|
+
store=None,
|
|
140
|
+
on_progress=None,
|
|
141
|
+
) -> tuple[str, str | None]:
|
|
142
|
+
"""
|
|
143
|
+
Run Claude Code against a managed repo for a human-requested task.
|
|
144
|
+
|
|
145
|
+
Returns (status, detail):
|
|
146
|
+
"done" → detail is pr_url (str | None)
|
|
147
|
+
"needs_human" → detail is reason string
|
|
148
|
+
"skip" → detail is reason string
|
|
149
|
+
"error" → detail is error string
|
|
150
|
+
"""
|
|
151
|
+
local_path = repo.local_path
|
|
152
|
+
if not local_path or not Path(local_path).exists():
|
|
153
|
+
return "error", f"Local clone not found at {local_path!r} — run `sentinel init` first"
|
|
154
|
+
|
|
155
|
+
env = _git_env(repo)
|
|
156
|
+
|
|
157
|
+
# Pull latest
|
|
158
|
+
r = subprocess.run(
|
|
159
|
+
["git", "pull", "--rebase", "origin", repo.branch],
|
|
160
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=60,
|
|
161
|
+
)
|
|
162
|
+
if r.returncode != 0:
|
|
163
|
+
logger.warning("git pull failed before repo task for %s: %s", repo.repo_name, r.stderr[:200])
|
|
164
|
+
|
|
165
|
+
before_hash = _get_head(local_path, env)
|
|
166
|
+
|
|
167
|
+
prompt = _build_repo_prompt(task, repo)
|
|
168
|
+
claude_log = Path(local_path).parent / "logs" / f"repo-task-{task.fingerprint[:8]}.log"
|
|
169
|
+
claude_log.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
if on_progress:
|
|
172
|
+
on_progress(f":mag: Exploring `{repo.repo_name}`...")
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
output, timed_out = _run_claude_attempt(
|
|
176
|
+
cfg.claude_code_bin, prompt, env,
|
|
177
|
+
cwd=local_path,
|
|
178
|
+
claude_log_path=claude_log,
|
|
179
|
+
on_progress=on_progress,
|
|
180
|
+
)
|
|
181
|
+
except FileNotFoundError:
|
|
182
|
+
return "error", f"Claude binary not found: {cfg.claude_code_bin}"
|
|
183
|
+
|
|
184
|
+
if timed_out:
|
|
185
|
+
return "error", "Claude timed out after 15 minutes."
|
|
186
|
+
|
|
187
|
+
stripped = output.strip()
|
|
188
|
+
|
|
189
|
+
if _is_auth_error(stripped):
|
|
190
|
+
return "error", "Claude authentication error — check API key or run `claude login`."
|
|
191
|
+
|
|
192
|
+
if stripped.upper().startswith("SKIP:"):
|
|
193
|
+
reason = stripped[5:].strip()
|
|
194
|
+
logger.info("Repo task skipped for %s: %s", repo.repo_name, reason[:200])
|
|
195
|
+
return "skip", reason
|
|
196
|
+
|
|
197
|
+
if stripped.upper().startswith("NEEDS_HUMAN:"):
|
|
198
|
+
reason = stripped[12:].strip()
|
|
199
|
+
logger.info("Repo task needs human for %s: %s", repo.repo_name, reason[:200])
|
|
200
|
+
return "needs_human", reason
|
|
201
|
+
|
|
202
|
+
if stripped.upper().startswith("BOSS_ESCALATE:"):
|
|
203
|
+
description = stripped[14:].strip()
|
|
204
|
+
try:
|
|
205
|
+
from .sentinel_dev import drop_escalation
|
|
206
|
+
drop_escalation(
|
|
207
|
+
Path(local_path).parent,
|
|
208
|
+
description,
|
|
209
|
+
source="repo_task/BOSS_ESCALATE",
|
|
210
|
+
source_fingerprint=task.fingerprint,
|
|
211
|
+
)
|
|
212
|
+
logger.info("Repo task escalated to Patch for %s: %s", repo.repo_name, description[:200])
|
|
213
|
+
except Exception as _e:
|
|
214
|
+
logger.error("Failed to drop BOSS_ESCALATE from repo task: %s", _e)
|
|
215
|
+
return "skip", f"Escalated to Patch: {description[:200]}"
|
|
216
|
+
|
|
217
|
+
# Check if Claude committed anything
|
|
218
|
+
after_hash = _get_head(local_path, env)
|
|
219
|
+
if after_hash == before_hash:
|
|
220
|
+
# Claude ran but didn't commit — check for staged/unstaged changes
|
|
221
|
+
diff_r = subprocess.run(
|
|
222
|
+
["git", "status", "--porcelain"],
|
|
223
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=10,
|
|
224
|
+
)
|
|
225
|
+
if diff_r.stdout.strip():
|
|
226
|
+
commit_msg = f"{task.task_type}(sentinel-task): {task.message[:60]} [sentinel-task]"
|
|
227
|
+
subprocess.run(["git", "add", "-A"], cwd=local_path, env=env, timeout=30, capture_output=True)
|
|
228
|
+
cr = subprocess.run(
|
|
229
|
+
["git", "commit", "-m", commit_msg],
|
|
230
|
+
cwd=local_path, env=env, timeout=30, capture_output=True, text=True,
|
|
231
|
+
)
|
|
232
|
+
if cr.returncode != 0:
|
|
233
|
+
logger.error("Auto-commit failed for %s: %s", repo.repo_name, cr.stderr[:200])
|
|
234
|
+
subprocess.run(["git", "checkout", "."], cwd=local_path, env=env, timeout=30, capture_output=True)
|
|
235
|
+
return "error", "Claude made changes but commit failed."
|
|
236
|
+
after_hash = _get_head(local_path, env)
|
|
237
|
+
else:
|
|
238
|
+
logger.warning("Repo task: Claude ran but made no changes for %s", repo.repo_name)
|
|
239
|
+
return "skip", "Claude completed but made no changes to the codebase."
|
|
240
|
+
|
|
241
|
+
commit_hash = after_hash
|
|
242
|
+
|
|
243
|
+
if on_progress:
|
|
244
|
+
on_progress(f":arrow_up: Pushing to `{repo.repo_name}`...")
|
|
245
|
+
|
|
246
|
+
if repo.auto_publish:
|
|
247
|
+
r = subprocess.run(
|
|
248
|
+
["git", "push", "origin", repo.branch],
|
|
249
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=60,
|
|
250
|
+
)
|
|
251
|
+
if r.returncode != 0:
|
|
252
|
+
logger.error("git push failed for %s: %s", repo.repo_name, r.stderr[:300])
|
|
253
|
+
return "error", f"git push failed: {r.stderr.strip()[:200]}"
|
|
254
|
+
logger.info("Repo task: pushed to %s/%s sha=%s", repo.repo_name, repo.branch, commit_hash[:8])
|
|
255
|
+
return "done", None
|
|
256
|
+
else:
|
|
257
|
+
branch = f"sentinel/task-{task.fingerprint[:8]}"
|
|
258
|
+
subprocess.run(["git", "checkout", "-B", branch], cwd=local_path, env=env,
|
|
259
|
+
timeout=30, capture_output=True)
|
|
260
|
+
r = subprocess.run(
|
|
261
|
+
["git", "push", "-u", "origin", branch],
|
|
262
|
+
cwd=local_path, capture_output=True, text=True, env=env, timeout=60,
|
|
263
|
+
)
|
|
264
|
+
if r.returncode != 0:
|
|
265
|
+
logger.error("git push branch failed for %s: %s", repo.repo_name, r.stderr[:300])
|
|
266
|
+
subprocess.run(["git", "checkout", repo.branch], cwd=local_path, env=env,
|
|
267
|
+
timeout=30, capture_output=True)
|
|
268
|
+
return "error", f"git push failed: {r.stderr.strip()[:200]}"
|
|
269
|
+
subprocess.run(["git", "checkout", repo.branch], cwd=local_path, env=env,
|
|
270
|
+
timeout=30, capture_output=True)
|
|
271
|
+
pr_url = _open_task_pr(repo, cfg, branch, task, commit_hash)
|
|
272
|
+
logger.info("Repo task: PR opened for %s: %s", repo.repo_name, pr_url)
|
|
273
|
+
return "done", pr_url or None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def drop_repo_task(
|
|
277
|
+
project_dir: Path,
|
|
278
|
+
repo_name: str,
|
|
279
|
+
task_type: str,
|
|
280
|
+
description: str,
|
|
281
|
+
submitter_user_id: str = "",
|
|
282
|
+
notify_user_ids: list | None = None,
|
|
283
|
+
) -> Path:
|
|
284
|
+
"""Drop a repo task file into <project_dir>/repo-tasks/."""
|
|
285
|
+
tasks_dir = project_dir / "repo-tasks"
|
|
286
|
+
tasks_dir.mkdir(exist_ok=True)
|
|
287
|
+
import uuid as _uuid
|
|
288
|
+
ts = int(time.time())
|
|
289
|
+
fname = f"{repo_name}-{_uuid.uuid4().hex[:8]}-{ts}.txt"
|
|
290
|
+
fpath = tasks_dir / fname
|
|
291
|
+
lines = [
|
|
292
|
+
f"REPO: {repo_name}",
|
|
293
|
+
f"TYPE: {task_type}",
|
|
294
|
+
(f"SUBMITTED_BY: <@{submitter_user_id}> ({submitter_user_id})"
|
|
295
|
+
if submitter_user_id else "SUBMITTED_BY: system"),
|
|
296
|
+
f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
|
|
297
|
+
]
|
|
298
|
+
if notify_user_ids:
|
|
299
|
+
lines.append(f"NOTIFY: {','.join(notify_user_ids)}")
|
|
300
|
+
lines += ["", description]
|
|
301
|
+
fpath.write_text("\n".join(lines), encoding="utf-8")
|
|
302
|
+
logger.info("Dropped repo task: %s", fname)
|
|
303
|
+
return fpath
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
|
|
307
|
+
"""Return all pending repo task files from <project_dir>/repo-tasks/."""
|
|
308
|
+
tasks_dir = project_dir / "repo-tasks"
|
|
309
|
+
if not tasks_dir.exists():
|
|
310
|
+
return []
|
|
311
|
+
tasks = []
|
|
312
|
+
for f in sorted(tasks_dir.iterdir()):
|
|
313
|
+
if not f.is_file() or f.name.startswith(".") or f.suffix.lower() not in (".txt", ".md", ""):
|
|
314
|
+
continue
|
|
315
|
+
try:
|
|
316
|
+
content = f.read_text(encoding="utf-8", errors="replace").strip()
|
|
317
|
+
except OSError:
|
|
318
|
+
continue
|
|
319
|
+
if not content:
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
lines = content.splitlines()
|
|
323
|
+
repo_name = ""
|
|
324
|
+
task_type = "feature"
|
|
325
|
+
submitter_user_id = ""
|
|
326
|
+
notify_user_ids: list = []
|
|
327
|
+
body_start = 0
|
|
328
|
+
|
|
329
|
+
for i, line in enumerate(lines):
|
|
330
|
+
stripped = line.strip()
|
|
331
|
+
upper = stripped.upper()
|
|
332
|
+
if upper.startswith("REPO:"):
|
|
333
|
+
repo_name = stripped[5:].strip()
|
|
334
|
+
body_start = i + 1
|
|
335
|
+
elif upper.startswith("TYPE:"):
|
|
336
|
+
raw = stripped[5:].strip().lower()
|
|
337
|
+
task_type = raw if raw in ("feature", "fix", "refactor", "chore") else "feature"
|
|
338
|
+
body_start = i + 1
|
|
339
|
+
elif upper.startswith("SUBMITTED_BY:"):
|
|
340
|
+
m = re.search(r'\(([UW][A-Z0-9]+)\)', stripped)
|
|
341
|
+
if m:
|
|
342
|
+
submitter_user_id = m.group(1)
|
|
343
|
+
body_start = i + 1
|
|
344
|
+
elif upper.startswith("NOTIFY:"):
|
|
345
|
+
notify_user_ids = [u.strip() for u in stripped[7:].split(",") if u.strip()]
|
|
346
|
+
body_start = i + 1
|
|
347
|
+
elif any(upper.startswith(p) for p in _META_PREFIXES) or not stripped:
|
|
348
|
+
body_start = i + 1
|
|
349
|
+
else:
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
description = "\n".join(lines[body_start:]).strip() or content
|
|
353
|
+
message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
|
|
354
|
+
|
|
355
|
+
if not repo_name:
|
|
356
|
+
logger.warning("Repo task %s has no REPO: header — skipping", f.name)
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
tasks.append(RepoTask(
|
|
360
|
+
task_file=f,
|
|
361
|
+
repo_name=repo_name,
|
|
362
|
+
task_type=task_type,
|
|
363
|
+
description=description,
|
|
364
|
+
message=message,
|
|
365
|
+
submitter_user_id=submitter_user_id,
|
|
366
|
+
notify_user_ids=notify_user_ids,
|
|
367
|
+
))
|
|
368
|
+
logger.info("Found repo task: %s → %s (type=%s)", f.name, repo_name, task_type)
|
|
369
|
+
|
|
370
|
+
return tasks
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def mark_repo_task_done(task_file: Path) -> None:
|
|
374
|
+
"""Archive a processed repo task to repo-tasks/.done/."""
|
|
375
|
+
done_dir = task_file.parent / ".done"
|
|
376
|
+
done_dir.mkdir(exist_ok=True)
|
|
377
|
+
dest = done_dir / task_file.name
|
|
378
|
+
if dest.exists():
|
|
379
|
+
dest = done_dir / f"{task_file.stem}-{int(time.time())}{task_file.suffix}"
|
|
380
|
+
task_file.rename(dest)
|
|
381
|
+
logger.info("Repo task archived: %s -> .done/%s", task_file.name, dest.name)
|