@misterhuydo/sentinel 1.4.86 → 1.4.88

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-27T05:09:05.068Z",
3
- "checkpoint_at": "2026-03-27T05:09:05.069Z",
2
+ "message": "Auto-checkpoint at 2026-03-27T05:16:34.970Z",
3
+ "checkpoint_at": "2026-03-27T05:16:34.971Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.86",
3
+ "version": "1.4.88",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -191,32 +191,118 @@ def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
191
191
  return [bin_path, "--bare", "--print", prompt]
192
192
 
193
193
 
194
+ # ── Claude output → human-readable progress ───────────────────────────────────
195
+
196
+ # Tool-use lines emitted by Claude Code look like: "⏺ ToolName(args...)"
197
+ _TOOL_USE_RE = re.compile(r'^[⏺⎆●✦]\s*(\w+)\s*\((.{0,120})', re.UNICODE)
198
+
199
+
200
+ def _progress_from_line(line: str) -> str | None:
201
+ """
202
+ Convert a raw Claude Code output line into a brief Slack progress message.
203
+ Returns None for lines that are too noisy to show.
204
+ """
205
+ stripped = line.strip()
206
+ m = _TOOL_USE_RE.match(stripped)
207
+ if not m:
208
+ return None
209
+
210
+ tool = m.group(1)
211
+ args = m.group(2).rstrip(')')
212
+
213
+ if tool == "Bash":
214
+ cmd = args.strip()
215
+ if re.search(r'\bmvn\b.*\btest\b|\bgradle\b.*\btest\b', cmd):
216
+ return ":test_tube: Running tests..."
217
+ if re.search(r'\byarn\b.*\btest\b|\bnpm\b.*\btest\b', cmd):
218
+ return ":test_tube: Running tests..."
219
+ if re.search(r'\byarn\b.*\binstall\b|\bnpm\b.*\binstall\b', cmd):
220
+ return ":package: Installing dependencies..."
221
+ if re.search(r'\byarn\b\s*$|\byarn\b\s+build', cmd):
222
+ return ":package: Running yarn build..."
223
+ if re.search(r'\bmvn\b', cmd):
224
+ return ":hammer: Running Maven..."
225
+ if re.search(r'\bgradle\b|gradlew', cmd):
226
+ return ":hammer: Running Gradle..."
227
+ if re.search(r'\bgit\b.*\bcommit\b', cmd):
228
+ return ":floppy_disk: Committing changes..."
229
+ if re.search(r'\bgit\b.*\bpush\b', cmd):
230
+ return ":arrow_up: Pushing to remote..."
231
+ if re.search(r'\bnpm\b\s+install\b|\bnpm\b\s+ci\b', cmd):
232
+ return ":package: Installing npm packages..."
233
+ return None # other bash — skip
234
+
235
+ if tool in ("Edit", "MultiEdit"):
236
+ fname = args.split(",")[0].strip().split("/")[-1]
237
+ if fname:
238
+ return f":pencil2: Patching `{fname}`"
239
+ return ":pencil2: Editing files..."
240
+
241
+ if tool == "Write":
242
+ fname = args.split(",")[0].strip().split("/")[-1]
243
+ if fname:
244
+ return f":pencil2: Writing `{fname}`"
245
+ return None
246
+
247
+ # Read, Glob, Grep, Agent, WebFetch → suppress
248
+ return None
249
+
250
+
194
251
  def _run_claude_attempt(
195
252
  bin_path: str,
196
253
  prompt: str,
197
254
  env: dict,
198
255
  cwd: str | None = None,
199
256
  claude_log_path: Path | None = None,
257
+ on_progress=None,
200
258
  ) -> tuple[str, bool]:
201
259
  """
202
260
  Run claude CLI with the given env. Returns (output, timed_out).
203
261
  Raises FileNotFoundError if binary is missing.
204
- If claude_log_path is given, writes the full prompt + raw output there.
262
+ If on_progress is given, calls on_progress(msg) for meaningful output lines
263
+ (deduped — same message not repeated consecutively).
205
264
  """
265
+ import threading as _threading
266
+
267
+ proc = subprocess.Popen(
268
+ _claude_cmd(bin_path, prompt),
269
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
270
+ text=True, env=env, cwd=cwd or None,
271
+ )
272
+
273
+ timed_out_flag = [False]
274
+ def _kill():
275
+ timed_out_flag[0] = True
276
+ try:
277
+ proc.kill()
278
+ except Exception:
279
+ pass
280
+ timer = _threading.Timer(SUBPROCESS_TIMEOUT, _kill)
281
+ timer.start()
282
+
283
+ lines = []
284
+ last_progress = [None]
206
285
  try:
207
- result = subprocess.run(
208
- _claude_cmd(bin_path, prompt),
209
- capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
210
- cwd=cwd or None,
211
- )
212
- output = (result.stdout or "") + (result.stderr or "")
213
- if claude_log_path:
214
- _write_claude_log(claude_log_path, prompt, output, timed_out=False)
215
- return output, False
216
- except subprocess.TimeoutExpired:
217
- if claude_log_path:
218
- _write_claude_log(claude_log_path, prompt, "", timed_out=True)
219
- return "", True
286
+ for line in proc.stdout:
287
+ lines.append(line)
288
+ if on_progress:
289
+ msg = _progress_from_line(line)
290
+ if msg and msg != last_progress[0]:
291
+ last_progress[0] = msg
292
+ try:
293
+ on_progress(msg)
294
+ except Exception:
295
+ pass
296
+ proc.wait()
297
+ except Exception:
298
+ pass
299
+ finally:
300
+ timer.cancel()
301
+
302
+ output = "".join(lines)
303
+ if claude_log_path:
304
+ _write_claude_log(claude_log_path, prompt, output, timed_out=timed_out_flag[0])
305
+ return output, timed_out_flag[0]
220
306
 
221
307
 
222
308
  def _write_claude_log(log_path: Path, prompt: str, output: str, timed_out: bool) -> None:
@@ -241,6 +327,7 @@ def generate_fix(
241
327
  cfg: SentinelConfig,
242
328
  patches_dir: Path,
243
329
  store=None,
330
+ on_progress=None,
244
331
  ) -> tuple[str, Path | None, str]:
245
332
  """
246
333
  Generate a fix for the given error event.
@@ -310,7 +397,8 @@ def generate_fix(
310
397
  continue
311
398
  logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
312
399
  output, timed_out = _run_claude_attempt(
313
- cfg.claude_code_bin, prompt, env, cwd=repo.local_path, claude_log_path=claude_log_path,
400
+ cfg.claude_code_bin, prompt, env, cwd=repo.local_path,
401
+ claude_log_path=claude_log_path, on_progress=on_progress,
314
402
  )
315
403
  if timed_out:
316
404
  logger.error("Claude Code timed out for %s", event.fingerprint)
@@ -316,8 +316,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
316
316
  )
317
317
  return # Leave the file so admin can add the header
318
318
 
319
- # Post "working on" to channel so everyone can see progress.
320
- from .notify import slack_alert as _slack_alert
319
+ # Post "working on" to channel and capture thread_ts for progress replies
320
+ from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
321
321
  _submitter = getattr(event, "submitter_user_id", "")
322
322
  _started_msg = (
323
323
  f":hammer: Working on *<@{_submitter}>*'s request — *{repo.repo_name}*\n"
@@ -325,15 +325,21 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
325
325
  ) if _submitter else (
326
326
  f":hammer: Working on *{repo.repo_name}*\n_{event.message[:120]}_"
327
327
  )
328
- _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
328
+ _thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
329
+
330
+ def _progress(msg: str) -> None:
331
+ """Post a threaded reply under the 'Working on' message."""
332
+ _slack_reply(sentinel.slack_bot_token, sentinel.slack_channel, _thread_ts, msg)
329
333
 
330
334
  try:
331
335
  patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
332
336
  _loop = asyncio.get_event_loop()
333
337
 
334
- # Run blocking subprocess work in thread executor so Boss stays responsive
338
+ # Run blocking subprocess work in thread executor so Boss stays responsive.
339
+ # _progress is thread-safe (HTTP POST) so pass it as streaming callback.
340
+ _progress(":brain: Analyzing with Claude Code...")
335
341
  status, patch_path, marker = await _loop.run_in_executor(
336
- None, generate_fix, event, repo, sentinel, patches_dir, store
342
+ None, generate_fix, event, repo, sentinel, patches_dir, store, _progress
337
343
  )
338
344
 
339
345
  submitter_uid = getattr(event, "submitter_user_id", "")
@@ -342,6 +348,7 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
342
348
  store.record_fix(event.fingerprint, "skipped" if status in ("skip", "needs_human") else "failed",
343
349
  repo_name=repo.repo_name)
344
350
  reason_text = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
351
+ _progress(f":x: Could not generate a safe fix — {reason_text[:120]}")
345
352
  notify_fix_blocked(sentinel, event.source, event.message,
346
353
  reason=reason_text, repo_name=repo.repo_name,
347
354
  submitter_user_id=submitter_uid)
@@ -349,11 +356,13 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
349
356
  return {"submitter": submitter_uid, "repo_name": repo.repo_name,
350
357
  "status": "blocked", "summary": reason_text[:120], "pr_url": ""}
351
358
 
359
+ _progress(f":mag: Patch generated — running tests (`{repo.repo_name}`)...")
352
360
  commit_status, commit_hash = await _loop.run_in_executor(
353
361
  None, apply_and_commit, event, patch_path, repo, sentinel
354
362
  )
355
363
  if commit_status != "committed":
356
364
  store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
365
+ _progress(":x: Tests failed or patch couldn't apply — needs human review")
357
366
  notify_fix_blocked(sentinel, event.source, event.message,
358
367
  reason="Patch was generated but commit/tests failed",
359
368
  repo_name=repo.repo_name,
@@ -362,6 +371,7 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
362
371
  return {"submitter": submitter_uid, "repo_name": repo.repo_name,
363
372
  "status": "blocked", "summary": "Commit/tests failed", "pr_url": ""}
364
373
 
374
+ _progress(f":white_check_mark: Tests passed — committed `{commit_hash[:8]}`, pushing to `{repo.branch}`...")
365
375
  branch, pr_url = publish(event, repo, sentinel, commit_hash)
366
376
  store.record_fix(
367
377
  event.fingerprint,
@@ -387,6 +397,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
387
397
  "auto_publish": repo.auto_publish,
388
398
  "files_changed": [],
389
399
  })
400
+ if pr_url:
401
+ _progress(f":arrow_right: <{pr_url}|PR opened> — awaiting review")
390
402
  notify_fix_applied(sentinel, event.source, event.message,
391
403
  repo_name=repo.repo_name, branch=branch, pr_url=pr_url,
392
404
  submitter_user_id=submitter_uid)
@@ -394,6 +406,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
394
406
 
395
407
  if repo.auto_publish:
396
408
  ok = cicd_trigger(repo, store, event.fingerprint)
409
+ if ok:
410
+ _progress(f":rocket: Release triggered via {repo.cicd_type}")
397
411
  if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
398
412
  _run_cascade(repo, sentinel, cfg_loader)
399
413
 
@@ -403,15 +417,18 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
403
417
  except MissingToolError as e:
404
418
  logger.warning("Missing tool for %s: %s", event.source, e)
405
419
  submitter_uid = getattr(event, "submitter_user_id", "")
420
+ _progress(f":wrench: `{e.tool}` not found — auto-installing...")
406
421
  installed = await _loop.run_in_executor(
407
422
  None, _auto_install_if_safe, e.tool, repo.local_path, sentinel, repo.repo_name, event.source
408
423
  )
409
424
  if not installed:
425
+ _progress(f":x: `{e.tool}` is not a known safe tool — manual install required")
410
426
  notify_missing_tool(sentinel, e.tool, repo.repo_name, event.source, submitter_uid)
411
427
  store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
412
428
  mark_done(event.issue_file)
413
429
  return {"submitter": submitter_uid, "repo_name": repo.repo_name,
414
430
  "status": "blocked", "summary": f"Missing tool: {e.tool}", "pr_url": ""}
431
+ _progress(f":white_check_mark: `{e.tool}` installed — retrying tests...")
415
432
  # Tool installed — retry apply_and_commit once
416
433
  try:
417
434
  commit_status, commit_hash = await _loop.run_in_executor(
@@ -419,6 +436,7 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
419
436
  )
420
437
  except MissingToolError as e2:
421
438
  logger.error("Still missing tool after auto-install: %s", e2)
439
+ _progress(f":x: Still missing `{e2.tool}` after install — manual fix needed")
422
440
  notify_missing_tool(sentinel, e2.tool, repo.repo_name, event.source, submitter_uid)
423
441
  store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
424
442
  mark_done(event.issue_file)
@@ -426,12 +444,14 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
426
444
  "status": "blocked", "summary": f"Missing tool: {e2.tool}", "pr_url": ""}
427
445
  if commit_status != "committed":
428
446
  store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
447
+ _progress(":x: Tests still failing after tool install — needs human review")
429
448
  notify_fix_blocked(sentinel, event.source, event.message,
430
449
  reason="Patch was generated but commit/tests failed after tool install",
431
450
  repo_name=repo.repo_name, submitter_user_id=submitter_uid)
432
451
  mark_done(event.issue_file)
433
452
  return {"submitter": submitter_uid, "repo_name": repo.repo_name,
434
453
  "status": "blocked", "summary": "Commit/tests failed after tool install", "pr_url": ""}
454
+ _progress(f":white_check_mark: Tests passed — committed `{commit_hash[:8]}`, pushing to `{repo.branch}`...")
435
455
  branch, pr_url = publish(event, repo, sentinel, commit_hash)
436
456
  store.record_fix(
437
457
  event.fingerprint,
@@ -447,12 +467,16 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
447
467
  "branch": branch, "pr_url": pr_url,
448
468
  "auto_publish": repo.auto_publish, "files_changed": [],
449
469
  })
470
+ if pr_url:
471
+ _progress(f":arrow_right: <{pr_url}|PR opened> — awaiting review")
450
472
  notify_fix_applied(sentinel, event.source, event.message,
451
473
  repo_name=repo.repo_name, branch=branch, pr_url=pr_url,
452
474
  submitter_user_id=submitter_uid)
453
475
  mark_done(event.issue_file)
454
476
  if repo.auto_publish:
455
477
  ok = cicd_trigger(repo, store, event.fingerprint)
478
+ if ok:
479
+ _progress(f":rocket: Release triggered via {repo.cicd_type}")
456
480
  if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
457
481
  _run_cascade(repo, sentinel, cfg_loader)
458
482
  return {"submitter": submitter_uid, "repo_name": repo.repo_name,
@@ -127,29 +127,43 @@ def rate_limit_message(source: str, raw: str) -> str:
127
127
 
128
128
  # ── Alert dispatcher ──────────────────────────────────────────────────────────
129
129
 
130
- def slack_alert(bot_token: str, channel: str, text: str) -> None:
130
+ def slack_alert(bot_token: str, channel: str, text: str, thread_ts: str = "") -> str:
131
131
  """
132
132
  Post a plain-text alert to a Slack channel.
133
133
  Best-effort: logs on failure, never raises.
134
+ Returns the message timestamp (ts) on success, "" on failure.
135
+ Pass thread_ts to reply inside an existing thread.
134
136
  """
135
137
  if not bot_token or not channel:
136
138
  logger.debug("slack_alert: no token/channel configured — logging only: %s", text[:120])
137
- return
139
+ return ""
138
140
  try:
141
+ payload: dict = {"channel": channel, "text": text}
142
+ if thread_ts:
143
+ payload["thread_ts"] = thread_ts
139
144
  resp = requests.post(
140
145
  "https://slack.com/api/chat.postMessage",
141
146
  headers={
142
147
  "Authorization": f"Bearer {bot_token}",
143
148
  "Content-Type": "application/json",
144
149
  },
145
- json={"channel": channel, "text": text},
150
+ json=payload,
146
151
  timeout=10,
147
152
  )
148
153
  data = resp.json()
149
154
  if not data.get("ok"):
150
155
  logger.warning("slack_alert: Slack API error: %s", data.get("error"))
156
+ return ""
157
+ return data.get("ts", "")
151
158
  except Exception as exc:
152
159
  logger.warning("slack_alert: failed to post: %s", exc)
160
+ return ""
161
+
162
+
163
+ def slack_thread_reply(bot_token: str, channel: str, thread_ts: str, text: str) -> None:
164
+ """Post a reply in an existing Slack thread. No-op if thread_ts is empty."""
165
+ if thread_ts:
166
+ slack_alert(bot_token, channel, text, thread_ts=thread_ts)
153
167
 
154
168
 
155
169