@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 CHANGED
@@ -1 +1 @@
1
- 2026-03-27T08:23:02.070Z
1
+ 2026-03-27T13:47:13.440Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-27T08:30:46.754Z",
3
- "checkpoint_at": "2026-03-27T08:30:46.755Z",
2
+ "message": "Auto-checkpoint at 2026-03-27T13:53:02.113Z",
3
+ "checkpoint_at": "2026-03-27T13:53:02.114Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/lib/add.js CHANGED
@@ -616,34 +616,22 @@ async function addFromGit(gitUrl, workspace) {
616
616
  generateWorkspaceScripts(workspace, {}, {}, {}, effectiveToken);
617
617
  }
618
618
 
619
- // Write private Slack tokens to slack.properties (gitignored) + set PRIVATE_SLACK=true
619
+ // Write private tokens to private_sentinel.properties (gitignored never committed)
620
620
  if (projectSlackBotToken || projectSlackAppToken) {
621
- // Write slack.properties (never committed — tokens stay off git)
622
- const slackProps = path.join(projectDir, 'slack.properties');
623
- const lines = ['# Private Slack tokens for this project — DO NOT COMMIT'];
621
+ const privateProps = path.join(projectDir, 'private_sentinel.properties');
622
+ const lines = ['# Private credentials for this project — DO NOT COMMIT'];
624
623
  if (projectSlackBotToken) lines.push(`SLACK_BOT_TOKEN=${projectSlackBotToken}`);
625
624
  if (projectSlackAppToken) lines.push(`SLACK_APP_TOKEN=${projectSlackAppToken}`);
626
- fs.writeFileSync(slackProps, lines.join('\n') + '\n');
627
- ok('Private Slack tokens → slack.properties (local only)');
625
+ fs.writeFileSync(privateProps, lines.join('\n') + '\n');
626
+ ok('Private tokens → private_sentinel.properties (local only)');
628
627
 
629
- // Ensure slack.properties is gitignored
628
+ // Ensure private_sentinel.properties is gitignored
630
629
  const gitignore = path.join(projectDir, '.gitignore');
631
- const ignoreEntry = 'slack.properties';
630
+ const ignoreEntry = 'private_sentinel.properties';
632
631
  const existing = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, 'utf8') : '';
633
632
  if (!existing.includes(ignoreEntry)) {
634
633
  fs.writeFileSync(gitignore, existing.trimEnd() + `\n${ignoreEntry}\n`);
635
- ok('.gitignore updated — slack.properties will not be committed');
636
- }
637
-
638
- // Set PRIVATE_SLACK=true in config/sentinel.properties
639
- const projProps = path.join(projectDir, 'config', 'sentinel.properties');
640
- if (fs.existsSync(projProps)) {
641
- let props = fs.readFileSync(projProps, 'utf8');
642
- props = /^#?\s*PRIVATE_SLACK\s*=/m.test(props)
643
- ? props.replace(/^#?\s*PRIVATE_SLACK\s*=.*/m, 'PRIVATE_SLACK=true')
644
- : props.trimEnd() + '\nPRIVATE_SLACK=true\n';
645
- fs.writeFileSync(projProps, props);
646
- ok('PRIVATE_SLACK=true set in config/sentinel.properties');
634
+ ok('.gitignore updated — private_sentinel.properties will not be committed');
647
635
  }
648
636
  }
649
637
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.90",
3
+ "version": "1.4.91",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -76,7 +76,6 @@ class SentinelConfig:
76
76
  sync_max_file_mb: int = 200 # truncate synced log files exceeding this size (MB)
77
77
  boss_mode: str = "standard" # standard | strict | fun
78
78
  sentinel_dev_repo_path: str = "" # path to Sentinel source repo for Dev Claude
79
- sentinel_dev_auto_publish: bool = False # if True, auto-publish + upgrade after version bump
80
79
 
81
80
 
82
81
  @dataclass
@@ -153,14 +152,20 @@ class ConfigLoader:
153
152
  project_d = _parse_properties(str(path))
154
153
  d.update({k: v for k, v in project_d.items() if v})
155
154
 
156
- # If PRIVATE_SLACK=true, load Slack tokens from <projectDir>/slack.properties
157
- # (gitignored tokens never live in the config repo).
158
- if d.get("PRIVATE_SLACK", "").lower() == "true":
155
+ # Load private_sentinel.properties if present (gitignored — tokens never in config repo).
156
+ # Supports any secret key: SLACK_BOT_TOKEN, SLACK_APP_TOKEN, GITHUB_TOKEN, etc.
157
+ # Falls back to legacy slack.properties (PRIVATE_SLACK=true) for existing installs.
158
+ _private = self.config_dir.parent / "private_sentinel.properties"
159
+ if _private.exists():
160
+ _pd = _parse_properties(str(_private))
161
+ d.update({k: v for k, v in _pd.items() if v})
162
+ logger.debug("Loaded private config from %s", _private)
163
+ elif d.get("PRIVATE_SLACK", "").lower() == "true":
159
164
  slack_props = self.config_dir.parent / "slack.properties"
160
165
  if slack_props.exists():
161
166
  slack_d = _parse_properties(str(slack_props))
162
167
  d.update({k: v for k, v in slack_d.items() if v})
163
- logger.debug("Loaded private Slack config from %s", slack_props)
168
+ logger.debug("Loaded private config from %s (legacy)", slack_props)
164
169
  else:
165
170
  logger.warning("PRIVATE_SLACK=true but %s not found — run `sentinel add` to create it", slack_props)
166
171
 
@@ -209,7 +214,6 @@ class ConfigLoader:
209
214
  raw_mode = d.get("BOSS_MODE", "standard").lower().strip()
210
215
  c.boss_mode = raw_mode if raw_mode in ("standard", "strict", "fun") else "standard"
211
216
  c.sentinel_dev_repo_path = d.get("SENTINEL_DEV_REPO_PATH", "")
212
- c.sentinel_dev_auto_publish = d.get("SENTINEL_DEV_AUTO_PUBLISH", "false").lower() == "true"
213
217
  self.sentinel = c
214
218
 
215
219
  def _load_log_sources(self):
@@ -14,6 +14,7 @@ File format:
14
14
  SOURCE: boss|fix_engine/BOSS_ESCALATE|self_repair|manual
15
15
  SOURCE_FINGERPRINT: <8-char error fingerprint> # optional
16
16
  SUBMITTED_AT: 2026-03-27T10:00:00+00:00
17
+ NOTIFY: U1234567,U7654321 # optional extra users to ping on completion
17
18
 
18
19
  Task description — what to implement, fix, or improve in Sentinel.
19
20
  """
@@ -30,7 +31,7 @@ import re
30
31
 
31
32
  logger = logging.getLogger(__name__)
32
33
 
33
- _META_PREFIXES = ("TYPE:", "SUBMITTED_BY:", "SOURCE:", "SOURCE_FINGERPRINT:", "SUBMITTED_AT:")
34
+ _META_PREFIXES = ("TYPE:", "SUBMITTED_BY:", "SOURCE:", "SOURCE_FINGERPRINT:", "SUBMITTED_AT:", "NOTIFY:")
34
35
 
35
36
 
36
37
  @dataclass
@@ -44,6 +45,7 @@ class DevTask:
44
45
  timestamp: str = ""
45
46
  submitter_user_id: str = ""
46
47
  source_fingerprint: str = "" # error fingerprint if from BOSS_ESCALATE
48
+ notify_user_ids: list = field(default_factory=list) # extra users to ping on completion
47
49
 
48
50
  def __post_init__(self):
49
51
  if not self.fingerprint:
@@ -89,6 +91,7 @@ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
89
91
  task_type = "feature"
90
92
  source_fingerprint = ""
91
93
  submitter_user_id = ""
94
+ notify_user_ids: list = []
92
95
  body_start = 0
93
96
 
94
97
  for i, line in enumerate(lines):
@@ -106,6 +109,10 @@ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
106
109
  elif upper.startswith("SOURCE_FINGERPRINT:"):
107
110
  source_fingerprint = stripped[19:].strip()
108
111
  body_start = i + 1
112
+ elif upper.startswith("NOTIFY:"):
113
+ raw_ids = stripped[7:].strip()
114
+ notify_user_ids = [u.strip() for u in raw_ids.split(",") if u.strip()]
115
+ body_start = i + 1
109
116
  elif any(upper.startswith(p) for p in _META_PREFIXES) or not stripped:
110
117
  body_start = i + 1
111
118
  else:
@@ -122,6 +129,7 @@ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
122
129
  task_type=task_type,
123
130
  submitter_user_id=submitter_user_id,
124
131
  source_fingerprint=source_fingerprint,
132
+ notify_user_ids=notify_user_ids,
125
133
  ))
126
134
  logger.info("Found dev task: %s (type=%s)", f.name, task_type)
127
135
 
@@ -129,7 +129,7 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
129
129
  "8. If the fix requires changing Sentinel's own source code (the monitoring/fix agent",
130
130
  " itself, not the application being monitored) — output exactly:",
131
131
  " BOSS_ESCALATE: <description of what needs to change in Sentinel>",
132
- " This escalates to the Sentinel Dev Claude agent who will implement it.",
132
+ " This escalates to Patch, the Sentinel dev agent, who will implement it.",
133
133
  ]
134
134
  return "\n".join(lines_out)
135
135
 
@@ -925,12 +925,12 @@ async def _sync_loop(cfg_loader: ConfigLoader):
925
925
  await asyncio.sleep(cfg_loader.sentinel.sync_interval_seconds)
926
926
 
927
927
 
928
- # ── Dev Claude agent (Sentinel self-improvement) ─────────────────────────────
928
+ # ── Patch agent (Sentinel self-improvement) ──────────────────────────────────
929
929
 
930
930
 
931
931
  def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
932
932
  """
933
- Boss-qualify a raw Dev Claude reason string (from NEEDS_HUMAN: or SKIP:).
933
+ Boss-qualify a raw Patch reason string (from NEEDS_HUMAN: or SKIP:).
934
934
 
935
935
  Passes the raw text through the Boss LLM to produce a clean, concise,
936
936
  user-friendly explanation — so users never see verbose Claude output
@@ -949,14 +949,14 @@ def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
949
949
  max_tokens=200,
950
950
  system=(
951
951
  "You are Sentinel Boss, a DevOps agent assistant. "
952
- "A child Dev Claude agent produced the following explanation for why it "
952
+ "Patch (an autonomous dev agent) produced the following explanation for why it "
953
953
  "could not complete a task. Rewrite it as a clear, concise (1-3 sentences), "
954
954
  "user-friendly message suitable for a Slack channel. "
955
955
  "Be direct and specific. Do not pad with pleasantries. "
956
- "Do not start with 'I' or 'The Dev Claude'. "
956
+ "Do not start with 'I' or mention 'Patch' by name. "
957
957
  "Output only the qualified message, nothing else."
958
958
  ),
959
- messages=[{"role": "user", "content": f"Dev Claude said:\n{raw[:1000]}"}],
959
+ messages=[{"role": "user", "content": f"Patch said:\n{raw[:1000]}"}],
960
960
  )
961
961
  qualified = _resp.content[0].text.strip() if _resp.content else raw[:280]
962
962
  return qualified[:400]
@@ -966,7 +966,7 @@ def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
966
966
 
967
967
 
968
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."""
969
+ """Execute a single dev task via Patch, post progress to Slack."""
970
970
  from .sentinel_dev import run_dev_task
971
971
  from .dev_watcher import mark_dev_done
972
972
  from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
@@ -974,9 +974,9 @@ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
974
974
  sentinel = cfg_loader.sentinel
975
975
  _submitter = task.submitter_user_id
976
976
  _started_msg = (
977
- f":brain: Dev Claude working on *<@{_submitter}>*'s request\n_{task.message[:120]}_"
977
+ f":wrench: Patch working on *<@{_submitter}>*'s request\n_{task.message[:120]}_"
978
978
  ) if _submitter else (
979
- f":brain: Dev Claude working on dev task\n_{task.message[:120]}_"
979
+ f":wrench: Patch working on dev task\n_{task.message[:120]}_"
980
980
  )
981
981
  _thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
982
982
 
@@ -989,49 +989,52 @@ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
989
989
  None, run_dev_task, task, sentinel, store, _progress
990
990
  )
991
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")
992
+ logger.exception("Patch: unexpected error on task %s", task.fingerprint[:8])
993
+ _progress(":x: Patch hit an unexpected error — check logs")
994
994
  mark_dev_done(task.task_file)
995
995
  return
996
996
 
997
997
  mark_dev_done(task.task_file)
998
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...",
999
+ # Build mention string: submitter + any extra notify users
1000
+ _notify_ids = list(task.notify_user_ids or [])
1001
+ if task.submitter_user_id:
1002
+ mentions = f"<@{task.submitter_user_id}> " + " ".join(
1003
+ f"<@{u}>" for u in _notify_ids if u != task.submitter_user_id
1005
1004
  )
1006
- elif status == "done":
1007
- ver = f" (`v{detail}`)" if detail else ""
1005
+ mentions = mentions.strip() + " "
1006
+ else:
1007
+ mentions = " ".join(f"<@{u}>" for u in _notify_ids)
1008
+ mentions = (mentions + " ") if mentions else ""
1009
+
1010
+ if status == "done":
1008
1011
  _slack_alert(
1009
1012
  sentinel.slack_bot_token, sentinel.slack_channel,
1010
- f"{mention}:white_check_mark: *Dev Claude finished*{ver} — changes committed to Sentinel source.",
1013
+ f"{mentions}:white_check_mark: *Patch finished* — changes committed to Sentinel source.",
1011
1014
  )
1012
1015
  elif status == "needs_human":
1013
- # Boss qualifies the raw Dev Claude explanation before surfacing to users
1016
+ # Boss qualifies the raw Patch explanation before surfacing to users
1014
1017
  qualified = _boss_qualify_dev_reason(detail, sentinel)
1015
1018
  _slack_alert(
1016
1019
  sentinel.slack_bot_token, sentinel.slack_channel,
1017
- f"{mention}:warning: *Dev task needs human input*\n{qualified}",
1020
+ f"{mentions}:warning: *Dev task needs human input*\n{qualified}",
1018
1021
  )
1019
1022
  elif status == "skip":
1020
1023
  qualified = _boss_qualify_dev_reason(detail, sentinel)
1021
1024
  _slack_alert(
1022
1025
  sentinel.slack_bot_token, sentinel.slack_channel,
1023
- f"{mention}:fast_forward: *Dev task skipped* — {qualified}",
1026
+ f"{mentions}:fast_forward: *Dev task skipped* — {qualified}",
1024
1027
  )
1025
1028
  else:
1026
1029
  _slack_alert(
1027
1030
  sentinel.slack_bot_token, sentinel.slack_channel,
1028
- f"{mention}:x: *Dev Claude error* on task `{task.fingerprint[:8]}` — {detail[:200]}",
1031
+ f"{mentions}:x: *Patch error* on task `{task.fingerprint[:8]}` — {detail[:200]}",
1029
1032
  )
1030
1033
 
1031
1034
 
1032
1035
  async def _dev_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
1033
1036
  """
1034
- Background task: poll dev-tasks/ every 60 s and dispatch to Dev Claude.
1037
+ Background task: poll dev-tasks/ every 60 s and dispatch to Patch.
1035
1038
  Also scans Sentinel's own log for errors and auto-queues self-repair tasks.
1036
1039
  """
1037
1040
  from .dev_watcher import (
@@ -1081,6 +1084,109 @@ async def _dev_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
1081
1084
  await asyncio.sleep(60)
1082
1085
 
1083
1086
 
1087
+ # ── Repo task agent (human-requested changes to managed repos) ────────────────
1088
+
1089
+ async def _handle_repo_task(task, repo_cfg, cfg_loader: ConfigLoader, store: StateStore):
1090
+ """Execute a single repo task via Claude Code, post progress to Slack."""
1091
+ from .repo_task_engine import run_repo_task, mark_repo_task_done
1092
+ from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
1093
+
1094
+ sentinel = cfg_loader.sentinel
1095
+ _submitter = task.submitter_user_id
1096
+ _started_msg = (
1097
+ f":hammer: Working on *<@{_submitter}>*'s request for `{task.repo_name}`\n_{task.message[:120]}_"
1098
+ ) if _submitter else (
1099
+ f":hammer: Working on repo task for `{task.repo_name}`\n_{task.message[:120]}_"
1100
+ )
1101
+ _thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
1102
+
1103
+ def _progress(msg: str) -> None:
1104
+ _slack_reply(sentinel.slack_bot_token, sentinel.slack_channel, _thread_ts, msg)
1105
+
1106
+ _loop = asyncio.get_event_loop()
1107
+ try:
1108
+ status, detail = await _loop.run_in_executor(
1109
+ None, run_repo_task, task, repo_cfg, sentinel, store, _progress,
1110
+ )
1111
+ except Exception:
1112
+ logger.exception("Repo task: unexpected error on task %s", task.fingerprint[:8])
1113
+ _progress(":x: Unexpected error — check logs")
1114
+ mark_repo_task_done(task.task_file)
1115
+ return
1116
+
1117
+ mark_repo_task_done(task.task_file)
1118
+
1119
+ # Build mention string: submitter + extra notify users
1120
+ _notify_ids = list(task.notify_user_ids or [])
1121
+ if task.submitter_user_id:
1122
+ mentions = f"<@{task.submitter_user_id}> " + " ".join(
1123
+ f"<@{u}>" for u in _notify_ids if u != task.submitter_user_id
1124
+ )
1125
+ mentions = mentions.strip() + " "
1126
+ else:
1127
+ mentions = " ".join(f"<@{u}>" for u in _notify_ids)
1128
+ mentions = (mentions + " ") if mentions else ""
1129
+
1130
+ if status == "done":
1131
+ if detail: # PR URL
1132
+ _slack_alert(
1133
+ sentinel.slack_bot_token, sentinel.slack_channel,
1134
+ f"{mentions}:white_check_mark: Done — PR opened for `{task.repo_name}`: {detail}",
1135
+ )
1136
+ else:
1137
+ _slack_alert(
1138
+ sentinel.slack_bot_token, sentinel.slack_channel,
1139
+ f"{mentions}:white_check_mark: Done — changes pushed to `{task.repo_name}/{repo_cfg.branch}`.",
1140
+ )
1141
+ elif status == "needs_human":
1142
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1143
+ _slack_alert(
1144
+ sentinel.slack_bot_token, sentinel.slack_channel,
1145
+ f"{mentions}:warning: *Task needs human input* (`{task.repo_name}`)\n{qualified}",
1146
+ )
1147
+ elif status == "skip":
1148
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1149
+ _slack_alert(
1150
+ sentinel.slack_bot_token, sentinel.slack_channel,
1151
+ f"{mentions}:fast_forward: Task skipped for `{task.repo_name}` — {qualified}",
1152
+ )
1153
+ else:
1154
+ _slack_alert(
1155
+ sentinel.slack_bot_token, sentinel.slack_channel,
1156
+ f"{mentions}:x: Task error for `{task.repo_name}` — {(detail or '')[:200]}",
1157
+ )
1158
+
1159
+
1160
+ async def _repo_task_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
1161
+ """Background task: poll repo-tasks/ every 60s and dispatch to Claude."""
1162
+ from .repo_task_engine import scan_repo_tasks, mark_repo_task_done
1163
+
1164
+ await asyncio.sleep(20)
1165
+
1166
+ while True:
1167
+ try:
1168
+ project_dir = Path(".")
1169
+ tasks = scan_repo_tasks(project_dir)
1170
+ if tasks:
1171
+ logger.info("Repo task: %d task(s) found", len(tasks))
1172
+ for task in tasks:
1173
+ # Resolve repo config — exact match then fuzzy
1174
+ repo_cfg = cfg_loader.repos.get(task.repo_name)
1175
+ if not repo_cfg:
1176
+ for rname, rcfg in cfg_loader.repos.items():
1177
+ if task.repo_name.lower() in rname.lower():
1178
+ repo_cfg = rcfg
1179
+ break
1180
+ if not repo_cfg:
1181
+ logger.warning("Repo task: no config for repo '%s' — skipping", task.repo_name)
1182
+ mark_repo_task_done(task.task_file)
1183
+ continue
1184
+ await _handle_repo_task(task, repo_cfg, cfg_loader, store)
1185
+ except Exception as e:
1186
+ logger.warning("Repo task poll loop error: %s", e)
1187
+ await asyncio.sleep(60)
1188
+
1189
+
1084
1190
  # ── Entry point ──────────────────────────────────────────────────────────────────────────────────
1085
1191
 
1086
1192
  def _log_auth_status(cfg: SentinelConfig) -> None:
@@ -1154,6 +1260,7 @@ async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
1154
1260
  asyncio.ensure_future(run_slack_bot(cfg_loader, store))
1155
1261
  if cfg_loader.sentinel.sentinel_dev_repo_path:
1156
1262
  asyncio.ensure_future(_dev_poll_loop(cfg_loader, store))
1263
+ asyncio.ensure_future(_repo_task_poll_loop(cfg_loader, store))
1157
1264
 
1158
1265
  while True:
1159
1266
  try: