@misterhuydo/sentinel 1.0.44 → 1.0.46

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-21T23:30:09.918Z",
3
- "checkpoint_at": "2026-03-21T23:30:09.918Z",
2
+ "message": "Auto-checkpoint at 2026-03-22T05:40:32.023Z",
3
+ "checkpoint_at": "2026-03-22T05:40:32.024Z",
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.0.44",
3
+ "version": "1.0.46",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -29,17 +29,20 @@ and opens GitHub PRs for admin review (or pushes directly if AUTO_PUBLISH=true).
29
29
  Your job:
30
30
  - Understand what the DevOps engineer needs in natural language
31
31
  - Query Sentinel's live state (errors, fixes, open PRs) on their behalf
32
- - Create issue reports when asked to investigate or fix something
32
+ - Deliver tasks/issues to the right project you know all projects in this workspace
33
33
  - Control Sentinel (pause/resume) when asked
34
34
  - Give honest, concise answers — you know this system inside out
35
+ - If a project name is unclear or ambiguous, ask the engineer to clarify — never guess
35
36
 
36
37
  What you can do (tools available):
37
38
 
38
39
  1. get_status — Show recent errors detected, fixes applied/pending, open PRs.
39
40
  e.g. "what happened today?", "any issues?", "show open PRs"
40
41
 
41
- 2. create_issue — Queue a manual fix request for Sentinel to investigate next poll.
42
- e.g. "look into this bug: ...", "investigate order creation failures"
42
+ 2. create_issue — Deliver a fix/task to any project in this workspace by short name.
43
+ You know all project names use list_projects if you're unsure.
44
+ If the project name is ambiguous or not found, ask to clarify.
45
+ e.g. "tell 1881 to fix X", "look into Y in elprint", "investigate Z"
43
46
 
44
47
  3. pause_sentinel — Create SENTINEL_PAUSE file to halt all auto-fix activity.
45
48
  e.g. "pause sentinel", "stop auto-fixing"
@@ -69,8 +72,11 @@ What you can do (tools available):
69
72
  11. list_errors — List recent errors from the state store, optionally filtered by repo or source.
70
73
  e.g. "show all errors today", "what errors hit elprint this week?"
71
74
 
72
- 12. pull_repo — Run git pull on one or all managed repos.
73
- e.g. "pull changes for sentinel-1881", "git pull all repos", "update the code"
75
+ 12. pull_repo — Run git pull on one or all managed application repos.
76
+ e.g. "pull changes", "git pull all repos", "update the code"
77
+
78
+ 13. pull_config — Run git pull on one or all Sentinel project config dirs.
79
+ e.g. "pull config for 1881", "update sentinel config", "pull all configs"
74
80
 
75
81
  Tone: direct, professional, like a senior engineer who owns the system.
76
82
  Don't pad responses. Don't say "Great question!" or "Certainly!".
@@ -104,20 +110,25 @@ _TOOLS = [
104
110
  {
105
111
  "name": "create_issue",
106
112
  "description": (
107
- "Queue a fix request for Sentinel to investigate on the next poll cycle. "
108
- "Use whenever the engineer reports a bug, customer complaint, or asks you "
109
- "to look into something specific. Include every detail they gave you."
113
+ "Deliver a fix/task request to a Sentinel project instance. "
114
+ "Use when the engineer says 'tell 1881 to do X', 'look into Y in project elprint', "
115
+ "'implement this in 1881: ...'. Can target any project by short name. "
116
+ "Defaults to the current project if no project is specified."
110
117
  ),
111
118
  "input_schema": {
112
119
  "type": "object",
113
120
  "properties": {
114
121
  "description": {
115
122
  "type": "string",
116
- "description": "Full problem description — everything the engineer told you",
123
+ "description": "Full task/problem description — everything the engineer told you",
124
+ },
125
+ "project": {
126
+ "type": "string",
127
+ "description": "Project short name to deliver to (e.g. '1881', 'elprint'). Omit for current project.",
117
128
  },
118
129
  "target_repo": {
119
130
  "type": "string",
120
- "description": "Repo name to assign to (omit to let Sentinel auto-route)",
131
+ "description": "Specific repo within the project (omit to let Sentinel auto-route)",
121
132
  },
122
133
  },
123
134
  "required": ["description"],
@@ -248,9 +259,68 @@ _TOOLS = [
248
259
  },
249
260
  },
250
261
  },
262
+ {
263
+ "name": "pull_config",
264
+ "description": (
265
+ "Run git pull on one or all Sentinel project config directories. "
266
+ "Projects are matched by short name ('1881', 'elprint') or full dir name ('sentinel-1881'). "
267
+ "Use for: 'pull config for 1881', 'update sentinel config', 'pull all configs'."
268
+ ),
269
+ "input_schema": {
270
+ "type": "object",
271
+ "properties": {
272
+ "project": {
273
+ "type": "string",
274
+ "description": "Project short name or dir name to pull (omit for all projects)",
275
+ },
276
+ },
277
+ },
278
+ },
251
279
  ]
252
280
 
253
281
 
282
+ # ── Workspace helpers ─────────────────────────────────────────────────────────
283
+
284
+ def _workspace_dir() -> Path:
285
+ return Path(".").resolve().parent
286
+
287
+ def _short_name(dir_name: str) -> str:
288
+ """'sentinel-1881' → '1881', 'sentinel-elprint' → 'elprint', others unchanged."""
289
+ if dir_name.startswith("sentinel-"):
290
+ return dir_name[len("sentinel-"):]
291
+ return dir_name
292
+
293
+ def _find_project_dirs(target: str = "") -> list[Path]:
294
+ """Return project dirs matching target (short or full name), or all if target empty."""
295
+ workspace = _workspace_dir()
296
+ results = []
297
+ try:
298
+ for d in sorted(workspace.iterdir()):
299
+ if not d.is_dir() or d.name in ("code", ".git"):
300
+ continue
301
+ if not (d / "config").exists():
302
+ continue
303
+ if target:
304
+ if target.lower() not in d.name.lower() and target.lower() not in _short_name(d.name).lower():
305
+ continue
306
+ results.append(d)
307
+ except Exception:
308
+ pass
309
+ return results
310
+
311
+ def _git_pull(path: Path) -> dict:
312
+ try:
313
+ r = subprocess.run(
314
+ ["git", "pull", "--rebase", "origin"],
315
+ cwd=str(path), capture_output=True, text=True, timeout=60,
316
+ )
317
+ last = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else "already up to date"
318
+ return {"status": "ok" if r.returncode == 0 else "error",
319
+ "detail": last if r.returncode == 0 else r.stderr.strip()}
320
+ except Exception as e:
321
+ return {"status": "error", "detail": str(e)}
322
+
323
+
254
324
  # ── Tool execution ────────────────────────────────────────────────────────────
255
325
 
256
326
  def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
@@ -290,16 +360,43 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
290
360
  if name == "create_issue":
291
361
  description = inputs["description"]
292
362
  target_repo = inputs.get("target_repo", "")
293
- issues_dir = Path("issues")
363
+ project_arg = inputs.get("project", "")
364
+
365
+ if project_arg:
366
+ project_dirs = _find_project_dirs(project_arg)
367
+ if not project_dirs:
368
+ all_names = [_short_name(d.name) for d in _find_project_dirs()]
369
+ return json.dumps({
370
+ "error": f"No project found matching '{project_arg}'",
371
+ "available_projects": all_names,
372
+ "action_needed": "Ask the user which project they meant.",
373
+ })
374
+ if len(project_dirs) > 1:
375
+ matches = [_short_name(d.name) for d in project_dirs]
376
+ return json.dumps({
377
+ "error": f"Ambiguous project name '{project_arg}' — matches: {matches}",
378
+ "action_needed": "Ask the user to clarify which project they mean.",
379
+ })
380
+ project_dir = project_dirs[0]
381
+ else:
382
+ project_dir = Path(".")
383
+
384
+ issues_dir = project_dir / "issues"
294
385
  issues_dir.mkdir(exist_ok=True)
295
386
  fname = f"slack-{uuid.uuid4().hex[:8]}.txt"
296
387
  content = (f"TARGET_REPO: {target_repo}\n\n" if target_repo else "") + description
297
388
  (issues_dir / fname).write_text(content, encoding="utf-8")
298
- logger.info("Boss created issue: %s", fname)
389
+
390
+ # Touch SENTINEL_POLL_NOW so the target instance picks it up immediately
391
+ (project_dir / "SENTINEL_POLL_NOW").touch()
392
+
393
+ project_label = _short_name(project_dir.resolve().name) if project_arg else "this project"
394
+ logger.info("Boss created issue for %s: %s", project_label, fname)
299
395
  return json.dumps({
300
- "status": "queued",
301
- "file": fname,
302
- "note": "Sentinel will pick this up on the next poll cycle.",
396
+ "status": "queued",
397
+ "project": project_label,
398
+ "file": fname,
399
+ "note": f"Delivered to '{project_label}'. Sentinel will process it on the next poll cycle.",
303
400
  })
304
401
 
305
402
  if name == "get_fix_details":
@@ -339,27 +436,11 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
339
436
  return json.dumps({"status": "resumed"})
340
437
 
341
438
  if name == "list_projects":
342
- # Repos this instance manages
343
- my_repos = [
344
- {
345
- "repo": r.repo_name,
346
- "url": r.repo_url,
347
- "branch": r.branch,
348
- "auto_publish": r.auto_publish,
349
- }
350
- for r in cfg_loader.repos.values()
351
- ]
352
- # Scan workspace for sibling project instances
353
- workspace = Path(".").resolve().parent
354
- other_projects = []
355
- try:
356
- for d in sorted(workspace.iterdir()):
357
- if not d.is_dir() or d.name in ("code", ".git"):
358
- continue
359
- repo_cfg_dir = d / "config" / "repo-configs"
360
- if not repo_cfg_dir.exists():
361
- continue
362
- repos_in_project = []
439
+ projects = []
440
+ for d in _find_project_dirs():
441
+ repo_cfg_dir = d / "config" / "repo-configs"
442
+ repos_in_project = []
443
+ if repo_cfg_dir.exists():
363
444
  for p in sorted(repo_cfg_dir.glob("*.properties")):
364
445
  if p.name.startswith("_"):
365
446
  continue
@@ -368,25 +449,15 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
368
449
  if line.startswith("REPO_URL"):
369
450
  repo_url = line.split("=", 1)[-1].strip()
370
451
  break
371
- if repo_url:
372
- repos_in_project.append({"repo": p.stem, "url": repo_url})
373
- if repos_in_project:
374
- pid_file = d / "sentinel.pid"
375
- running = pid_file.exists()
376
- other_projects.append({
377
- "project": d.name,
378
- "running": running,
379
- "repos": repos_in_project,
380
- })
381
- except Exception as e:
382
- logger.warning("list_projects workspace scan failed: %s", e)
383
- return json.dumps({
384
- "this_instance": {
385
- "project": Path(".").resolve().name,
386
- "repos": my_repos,
387
- },
388
- "workspace_projects": other_projects,
389
- })
452
+ repos_in_project.append({"repo": p.stem, "url": repo_url})
453
+ projects.append({
454
+ "project": _short_name(d.name),
455
+ "dir": d.name,
456
+ "running": (d / "sentinel.pid").exists(),
457
+ "this": d.resolve() == Path(".").resolve(),
458
+ "repos": repos_in_project,
459
+ })
460
+ return json.dumps({"projects": projects})
390
461
 
391
462
  if name == "search_logs":
392
463
  query = inputs.get("query", "")
@@ -482,6 +553,18 @@ def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
482
553
  results.append({"repo": repo_name, "status": "error", "detail": str(e)})
483
554
  return json.dumps({"results": results})
484
555
 
556
+ if name == "pull_config":
557
+ target = inputs.get("project", "")
558
+ dirs = _find_project_dirs(target)
559
+ if not dirs:
560
+ return json.dumps({"error": f"No project found matching '{target}'"})
561
+ results = []
562
+ for d in dirs:
563
+ res = _git_pull(d)
564
+ results.append({"project": _short_name(d.name), "dir": d.name, **res})
565
+ logger.info("Boss: pull_config %s → %s", d.name, res["status"])
566
+ return json.dumps({"results": results})
567
+
485
568
  return json.dumps({"error": f"unknown tool: {name}"})
486
569
 
487
570
 
@@ -617,14 +700,16 @@ async def handle_message(
617
700
  client = anthropic.Anthropic(api_key=api_key)
618
701
 
619
702
  # Build system context snapshot
620
- paused = Path("SENTINEL_PAUSE").exists()
621
- repos = list(cfg_loader.repos.keys())
622
- ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
703
+ paused = Path("SENTINEL_PAUSE").exists()
704
+ repos = list(cfg_loader.repos.keys())
705
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
706
+ known_projects = [_short_name(d.name) for d in _find_project_dirs()]
623
707
  system = (
624
708
  _SYSTEM
625
709
  + f"\n\nCurrent time: {ts}"
626
710
  + f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
627
711
  + f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
712
+ + (f"\nKnown projects in workspace: {', '.join(known_projects)}" if known_projects else "")
628
713
  )
629
714
 
630
715
  history.append({"role": "user", "content": message})