@misterhuydo/sentinel 1.4.88 → 1.4.90

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-27T05:10:33.256Z
1
+ 2026-03-27T08:23:02.070Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-27T05:16:34.970Z",
3
- "checkpoint_at": "2026-03-27T05:16:34.971Z",
2
+ "message": "Auto-checkpoint at 2026-03-27T08:30:46.754Z",
3
+ "checkpoint_at": "2026-03-27T08:30:46.755Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
@@ -1 +1,8 @@
1
- {}
1
+ {
2
+ "J:\\Projects\\Sentinel\\cli\\lib\\test.js": {
3
+ "tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\ff8fde_test.js",
4
+ "state": "compressed",
5
+ "minifiedAt": 1774252437350.0059,
6
+ "readCount": 1
7
+ }
8
+ }
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { execSync, spawnSync } = require('child_process');
6
+ const chalk = require('chalk');
7
+ const ok = msg => console.log(chalk.green(' ✔'), msg);
8
+ const fail = msg => console.log(chalk.red(' ✖'), msg);
9
+ const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
10
+ const info = msg => console.log(chalk.cyan(' →'), msg);
11
+ module.exports = async function testInstall(projectName) {
12
+ const defaultWorkspace = path.join(os.homedir(), 'sentinel');
13
+ const projectDir = projectName
14
+ ? path.join(defaultWorkspace, projectName)
15
+ : _findActiveProject(defaultWorkspace);
16
+ console.log(chalk.bold('\nSentinel — Installation Check\n'));
17
+ let passed = 0;
18
+ let failed = 0;
19
+ info('Checking required tools...');
20
+ const tools = [
21
+ { cmd: 'python3 --version', label: 'Python 3' },
22
+ { cmd: 'node --version', label: 'Node.js' },
23
+ { cmd: 'git --version', label: 'git' },
24
+ ];
25
+ for (const { cmd, label } of tools) {
26
+ try {
27
+ const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
28
+ ok(`${label}: ${out}`);
29
+ passed++;
30
+ } catch {
31
+ fail(`${label} not found`);
32
+ failed++;
33
+ }
34
+ }
35
+ info('Checking npm globals...');
36
+ const npms = [
37
+ { cmd: 'sentinel --version', label: '@misterhuydo/sentinel' },
38
+ { cmd: 'cairn --version', label: '@misterhuydo/cairn-mcp' },
39
+ { cmd: 'claude --version', label: '@anthropic-ai/claude-code'},
40
+ ];
41
+ for (const { cmd, label } of npms) {
42
+ try {
43
+ const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
44
+ ok(`${label}: ${out}`);
45
+ passed++;
46
+ } catch {
47
+ fail(`${label} not installed — run: npm install -g ${label}`);
48
+ failed++;
49
+ }
50
+ }
51
+ info('Checking Claude auth...');
52
+ const apiKey = _readSentinelProp(projectDir, 'ANTHROPIC_API_KEY')
53
+ || process.env.ANTHROPIC_API_KEY || '';
54
+ const claudeProTasks = (_readSentinelProp(projectDir, 'CLAUDE_PRO_FOR_TASKS') || 'true').toLowerCase() !== 'false';
55
+ if (apiKey) {
56
+ ok(`ANTHROPIC_API_KEY configured (${apiKey.slice(0, 12)}...)`);
57
+ passed++;
58
+ } else {
59
+ warn('ANTHROPIC_API_KEY not set — Sentinel Boss will use CLI fallback only');
60
+ }
61
+ try {
62
+ const r = spawnSync('claude', ['--version'], { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
63
+ if (r.status === 0) {
64
+ ok(`claude CLI available: ${(r.stdout || '').trim()}`);
65
+ if (claudeProTasks) {
66
+ ok('CLAUDE_PRO_FOR_TASKS=true — fix_engine will use Claude Pro subscription');
67
+ }
68
+ passed++;
69
+ } else {
70
+ warn('claude CLI found but --version failed');
71
+ }
72
+ } catch {
73
+ fail('claude CLI not found');
74
+ failed++;
75
+ }
76
+ if (!apiKey) {
77
+ try {
78
+ const r = spawnSync('claude', ['--print', 'ping', '--no-interactive'],
79
+ { encoding: 'utf8', timeout: 10000, stdio: ['pipe','pipe','pipe'] });
80
+ if (r.status === 0) {
81
+ ok('claude OAuth session active');
82
+ passed++;
83
+ } else {
84
+ warn('claude OAuth session may be expired — run: claude login');
85
+ }
86
+ } catch {
87
+ warn('Could not verify claude OAuth session');
88
+ }
89
+ }
90
+ if (projectDir && fs.existsSync(projectDir)) {
91
+ info(`Checking project config at ${projectDir}...`);
92
+ const sentinelProps = path.join(projectDir, 'config', 'sentinel.properties');
93
+ if (fs.existsSync(sentinelProps)) {
94
+ ok('sentinel.properties found');
95
+ passed++;
96
+ } else {
97
+ fail(`sentinel.properties missing at ${sentinelProps}`);
98
+ failed++;
99
+ }
100
+ const logConfigs = path.join(projectDir, 'config', 'log-configs');
101
+ const repoConfigs = path.join(projectDir, 'config', 'repo-configs');
102
+ const logCount = fs.existsSync(logConfigs) ? fs.readdirSync(logConfigs).filter(f => f.endsWith('.properties') && !f.startsWith('_')).length : 0;
103
+ const repoCount = fs.existsSync(repoConfigs) ? fs.readdirSync(repoConfigs).filter(f => f.endsWith('.properties') && !f.startsWith('_')).length : 0;
104
+ if (logCount > 0) { ok(`${logCount} log-config(s) found`); passed++; }
105
+ else { warn('No log-configs found — add at least one in config/log-configs/'); }
106
+ if (repoCount > 0) { ok(`${repoCount} repo-config(s) found`); passed++; }
107
+ else { warn('No repo-configs found — add at least one in config/repo-configs/'); }
108
+ const ghToken = _readSentinelProp(projectDir, 'GITHUB_TOKEN') || '';
109
+ if (ghToken) { ok('GITHUB_TOKEN configured'); passed++; }
110
+ else { warn('GITHUB_TOKEN not set — cannot open PRs'); }
111
+ const slackBot = _readSentinelProp(projectDir, 'SLACK_BOT_TOKEN') || '';
112
+ const slackApp = _readSentinelProp(projectDir, 'SLACK_APP_TOKEN') || '';
113
+ if (slackBot && slackApp) { ok('Slack tokens configured (Boss enabled)'); passed++; }
114
+ else { warn('Slack tokens not set — Boss disabled'); }
115
+ } else if (projectName) {
116
+ fail(`Project '${projectName}' not found at ${projectDir}`);
117
+ failed++;
118
+ } else {
119
+ warn('No project specified — skipping project config checks');
120
+ warn('Run: sentinel test <project-name>');
121
+ }
122
+ info('Checking Python dependencies...');
123
+ const codeDir = path.join(defaultWorkspace, 'code');
124
+ const reqFile = path.join(codeDir, 'requirements.txt');
125
+ if (fs.existsSync(reqFile)) {
126
+ try {
127
+ execSync('python3 -c "import paramiko, schedule, requests, jinja2"',
128
+ { encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
129
+ ok('Python dependencies installed');
130
+ passed++;
131
+ } catch {
132
+ fail('Python dependencies missing — run: pip install -r requirements.txt');
133
+ failed++;
134
+ }
135
+ } else {
136
+ warn('requirements.txt not found — run: sentinel init');
137
+ }
138
+ console.log('');
139
+ if (failed === 0) {
140
+ console.log(chalk.green.bold(` ✔ All checks passed (${passed} ok)`));
141
+ } else {
142
+ console.log(chalk.yellow.bold(` ${passed} passed, ${failed} failed`));
143
+ console.log(chalk.gray(' Fix the issues above before starting Sentinel.'));
144
+ }
145
+ console.log('');
146
+ process.exit(failed > 0 ? 1 : 0);
147
+ };
148
+ function _findActiveProject(workspace) {
149
+ if (!fs.existsSync(workspace)) return null;
150
+ const dirs = fs.readdirSync(workspace)
151
+ .map(d => path.join(workspace, d))
152
+ .filter(d => fs.statSync(d).isDirectory()
153
+ && fs.existsSync(path.join(d, 'config', 'sentinel.properties')));
154
+ return dirs.length === 1 ? dirs[0] : null;
155
+ }
156
+ function _readSentinelProp(projectDir, key) {
157
+ if (!projectDir) return '';
158
+ const candidates = [
159
+ path.join(projectDir, 'config', 'sentinel.properties'),
160
+ path.join(os.homedir(), 'sentinel', 'sentinel.properties'),
161
+ ];
162
+ for (const f of candidates) {
163
+ if (!fs.existsSync(f)) continue;
164
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
165
+ const stripped = line.trim();
166
+ if (stripped.startsWith('#') || !stripped.includes('=')) continue;
167
+ const [k, ...rest] = stripped.split('=');
168
+ if (k.trim() === key) return rest.join('=').split('#')[0].trim();
169
+ }
170
+ }
171
+ return '';
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.88",
3
+ "version": "1.4.90",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -75,6 +75,8 @@ class SentinelConfig:
75
75
  sync_retention_days: int = 30 # delete synced log files older than this many days
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
+ 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
78
80
 
79
81
 
80
82
  @dataclass
@@ -206,6 +208,8 @@ class ConfigLoader:
206
208
  c.sync_max_file_mb = int(d.get("SYNC_MAX_FILE_MB", 200))
207
209
  raw_mode = d.get("BOSS_MODE", "standard").lower().strip()
208
210
  c.boss_mode = raw_mode if raw_mode in ("standard", "strict", "fun") else "standard"
211
+ 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"
209
213
  self.sentinel = c
210
214
 
211
215
  def _load_log_sources(self):
@@ -0,0 +1,280 @@
1
+ """
2
+ dev_watcher.py — Scan the dev-tasks/ directory for Sentinel self-improvement requests,
3
+ and scan Sentinel's own log for errors to self-repair.
4
+
5
+ Tasks are dropped by:
6
+ - Boss (via dev_task tool) → slack-<uuid>.txt
7
+ - Fix engine (via BOSS_ESCALATE) → bot-<fingerprint>-<ts>.txt
8
+ - Self-repair (log watcher) → self-<fingerprint>-<ts>.txt
9
+ - Admin (manual drop) → any other .txt
10
+
11
+ File format:
12
+ TYPE: feature|fix|refactor|chore|ask
13
+ SUBMITTED_BY: Name (UXXX)
14
+ SOURCE: boss|fix_engine/BOSS_ESCALATE|self_repair|manual
15
+ SOURCE_FINGERPRINT: <8-char error fingerprint> # optional
16
+ SUBMITTED_AT: 2026-03-27T10:00:00+00:00
17
+
18
+ Task description — what to implement, fix, or improve in Sentinel.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import logging
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ import re
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _META_PREFIXES = ("TYPE:", "SUBMITTED_BY:", "SOURCE:", "SOURCE_FINGERPRINT:", "SUBMITTED_AT:")
34
+
35
+
36
+ @dataclass
37
+ class DevTask:
38
+ source: str # "dev-tasks/<filename>"
39
+ task_file: Path # absolute path, used for archiving
40
+ message: str # first non-blank body line — shown in Slack
41
+ body: str # full task body (after headers stripped)
42
+ task_type: str # feature|fix|refactor|chore|ask
43
+ fingerprint: str = ""
44
+ timestamp: str = ""
45
+ submitter_user_id: str = ""
46
+ source_fingerprint: str = "" # error fingerprint if from BOSS_ESCALATE
47
+
48
+ def __post_init__(self):
49
+ if not self.fingerprint:
50
+ raw = f"dev:{self.task_type}:{self.message[:200]}"
51
+ self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
52
+ if not self.timestamp:
53
+ self.timestamp = datetime.now(timezone.utc).isoformat()
54
+ if not self.submitter_user_id:
55
+ for line in self.body.splitlines():
56
+ m = re.match(r'SUBMITTED_BY:.*\(([UW][A-Z0-9]+)\)', line.strip())
57
+ if m:
58
+ self.submitter_user_id = m.group(1)
59
+ break
60
+
61
+
62
+ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
63
+ """Return all pending dev task files from <project_dir>/dev-tasks/."""
64
+ tasks_dir = project_dir / "dev-tasks"
65
+ tasks_dir.mkdir(exist_ok=True)
66
+
67
+ def _priority(p: Path) -> tuple:
68
+ if p.name.startswith("slack-"):
69
+ return (0, p.name)
70
+ if p.name.startswith("bot-"):
71
+ return (2, p.name)
72
+ return (1, p.name)
73
+
74
+ tasks = []
75
+ for f in sorted(tasks_dir.iterdir(), key=_priority):
76
+ if not f.is_file() or f.name.startswith("."):
77
+ continue
78
+ if f.suffix.lower() not in (".txt", ".md", ""):
79
+ continue
80
+ try:
81
+ content = f.read_text(encoding="utf-8", errors="replace").strip()
82
+ except OSError as e:
83
+ logger.error("Cannot read dev task %s: %s", f, e)
84
+ continue
85
+ if not content:
86
+ continue
87
+
88
+ lines = content.splitlines()
89
+ task_type = "feature"
90
+ source_fingerprint = ""
91
+ submitter_user_id = ""
92
+ body_start = 0
93
+
94
+ for i, line in enumerate(lines):
95
+ stripped = line.strip()
96
+ upper = stripped.upper()
97
+ if upper.startswith("TYPE:"):
98
+ raw = stripped[5:].strip().lower()
99
+ task_type = raw if raw in ("feature", "fix", "refactor", "chore", "ask") else "feature"
100
+ body_start = i + 1
101
+ elif upper.startswith("SUBMITTED_BY:"):
102
+ m = re.search(r'\(([UW][A-Z0-9]+)\)', stripped)
103
+ if m:
104
+ submitter_user_id = m.group(1)
105
+ body_start = i + 1
106
+ elif upper.startswith("SOURCE_FINGERPRINT:"):
107
+ source_fingerprint = stripped[19:].strip()
108
+ body_start = i + 1
109
+ elif any(upper.startswith(p) for p in _META_PREFIXES) or not stripped:
110
+ body_start = i + 1
111
+ else:
112
+ break
113
+
114
+ body = "\n".join(lines[body_start:]).strip() or content
115
+ message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
116
+
117
+ tasks.append(DevTask(
118
+ source=f"dev-tasks/{f.name}",
119
+ task_file=f,
120
+ message=message,
121
+ body=body,
122
+ task_type=task_type,
123
+ submitter_user_id=submitter_user_id,
124
+ source_fingerprint=source_fingerprint,
125
+ ))
126
+ logger.info("Found dev task: %s (type=%s)", f.name, task_type)
127
+
128
+ return tasks
129
+
130
+
131
+ def mark_dev_done(task_file: Path) -> None:
132
+ """Archive a processed dev task to dev-tasks/.done/ regardless of outcome."""
133
+ done_dir = task_file.parent / ".done"
134
+ done_dir.mkdir(exist_ok=True)
135
+ dest = done_dir / task_file.name
136
+ if dest.exists():
137
+ dest = done_dir / f"{task_file.stem}-{int(time.time())}{task_file.suffix}"
138
+ task_file.rename(dest)
139
+ logger.info("Dev task archived: %s -> .done/%s", task_file.name, dest.name)
140
+
141
+
142
+ _LOG_TIMESTAMP_RE = re.compile(
143
+ r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+ '
144
+ r'(ERROR|CRITICAL)\s+(\S+) — (.+)$'
145
+ )
146
+ _LOG_LINE_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+ \w')
147
+
148
+ # Error patterns in Sentinel's own code that are worth Dev Claude investigating.
149
+ # Excludes transient/external errors that Dev Claude cannot fix.
150
+ _SKIP_PATTERNS = re.compile(
151
+ r'SSH|Connection|Timeout|Rate.limit|npm show|git pull|git push'
152
+ r'|slack_bolt|urllib|requests\.|socket\.',
153
+ re.IGNORECASE,
154
+ )
155
+
156
+
157
+ def scan_sentinel_errors(
158
+ log_path: Path,
159
+ tail_lines: int = 300,
160
+ seen_fps: set | None = None,
161
+ ) -> list[tuple[str, str]]:
162
+ """
163
+ Scan Sentinel's own log file for recent ERROR/CRITICAL entries and
164
+ group them with their Traceback blocks.
165
+
166
+ Returns list of (fingerprint, error_text) for errors not in seen_fps.
167
+ Each error_text is suitable as a Dev Claude task body.
168
+ seen_fps is updated in-place so callers can track across polls.
169
+ """
170
+ if seen_fps is None:
171
+ seen_fps = set()
172
+
173
+ if not log_path.exists():
174
+ return []
175
+
176
+ try:
177
+ # Read last tail_lines lines efficiently
178
+ with open(log_path, encoding="utf-8", errors="replace") as f:
179
+ lines = f.readlines()
180
+ lines = lines[-tail_lines:]
181
+ except OSError:
182
+ return []
183
+
184
+ # Group lines into blocks: each block starts at an ERROR/CRITICAL log line
185
+ blocks: list[list[str]] = []
186
+ current: list[str] | None = None
187
+
188
+ for line in lines:
189
+ if _LOG_LINE_RE.match(line):
190
+ if current is not None:
191
+ blocks.append(current)
192
+ m = _LOG_TIMESTAMP_RE.match(line.rstrip())
193
+ current = [line.rstrip()] if m else None
194
+ elif current is not None:
195
+ current.append(line.rstrip())
196
+
197
+ if current:
198
+ blocks.append(current)
199
+
200
+ results = []
201
+ for block in blocks:
202
+ if not block:
203
+ continue
204
+ header = block[0]
205
+ m = _LOG_TIMESTAMP_RE.match(header)
206
+ if not m:
207
+ continue
208
+
209
+ level, logger_name, message = m.group(1), m.group(2), m.group(3)
210
+
211
+ # Skip errors from external systems that Dev Claude can't fix
212
+ if _SKIP_PATTERNS.search(message):
213
+ continue
214
+ # Skip if no traceback (single-line errors are often transient)
215
+ has_traceback = any("Traceback" in l or "Error:" in l for l in block[1:])
216
+ if not has_traceback and level != "CRITICAL":
217
+ continue
218
+
219
+ # Fingerprint: hash of normalized message + top traceback frame
220
+ top_frame = next(
221
+ (l.strip() for l in block if l.strip().startswith("File ")), ""
222
+ )
223
+ normalized = re.sub(r'0x[0-9a-f]+|\d+', 'N', message)
224
+ raw = f"sentinel-self:{logger_name}:{normalized}:{top_frame[:80]}"
225
+ fp = hashlib.sha1(raw.encode()).hexdigest()[:16]
226
+
227
+ if fp in seen_fps:
228
+ continue
229
+ seen_fps.add(fp)
230
+
231
+ error_text = "\n".join(block)
232
+ task_body = (
233
+ f"Sentinel detected an error in its own code (logger: {logger_name}).\n"
234
+ f"Investigate the root cause in the Sentinel source and fix it.\n\n"
235
+ f"LOG ENTRY:\n{error_text}\n"
236
+ )
237
+ results.append((fp, task_body))
238
+
239
+ return results
240
+
241
+
242
+ def drop_self_repair_task(project_dir: Path, fp: str, task_body: str) -> Path:
243
+ """
244
+ Create a self-repair dev task file for an error found in Sentinel's own log.
245
+ Returns the path to the created file.
246
+ """
247
+ dev_tasks_dir = project_dir / "dev-tasks"
248
+ dev_tasks_dir.mkdir(exist_ok=True)
249
+ ts = int(time.time())
250
+ fname = f"self-{fp[:8]}-{ts}.txt"
251
+ fpath = dev_tasks_dir / fname
252
+ lines = [
253
+ "TYPE: fix",
254
+ "SOURCE: self_repair",
255
+ f"SOURCE_FINGERPRINT: {fp}",
256
+ f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
257
+ "",
258
+ task_body,
259
+ ]
260
+ fpath.write_text("\n".join(lines), encoding="utf-8")
261
+ logger.info("Self-repair task dropped: %s", fname)
262
+ return fpath
263
+
264
+
265
+ def purge_old_dev_tasks(dev_tasks_dir: Path, keep_days: int = 30) -> int:
266
+ """Delete files older than keep_days from dev-tasks/.done/."""
267
+ cutoff = time.time() - keep_days * 86400
268
+ deleted = 0
269
+ done = dev_tasks_dir / ".done"
270
+ if done.exists():
271
+ for f in done.iterdir():
272
+ if f.is_file() and f.stat().st_mtime < cutoff:
273
+ try:
274
+ f.unlink()
275
+ deleted += 1
276
+ except OSError:
277
+ pass
278
+ if deleted:
279
+ logger.info("Purged %d old dev task archive file(s) (>%d days)", deleted, keep_days)
280
+ return deleted
@@ -126,6 +126,10 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
126
126
  " was insufficient or unsafe, (c) exactly what a human needs to do or decide.",
127
127
  " Do NOT output NEEDS_HUMAN just because the fix is complex — only when human",
128
128
  " judgement or access is genuinely required.",
129
+ "8. If the fix requires changing Sentinel's own source code (the monitoring/fix agent",
130
+ " itself, not the application being monitored) — output exactly:",
131
+ " BOSS_ESCALATE: <description of what needs to change in Sentinel>",
132
+ " This escalates to the Sentinel Dev Claude agent who will implement it.",
129
133
  ]
130
134
  return "\n".join(lines_out)
131
135
 
@@ -444,6 +448,27 @@ def generate_fix(
444
448
  logger.info("Claude skipped fix for %s: %s", event.fingerprint, reason)
445
449
  return "skip", None, reason
446
450
 
451
+ # BOSS_ESCALATE: child Claude determined the fix requires changing Sentinel itself
452
+ if output.strip().upper().startswith("BOSS_ESCALATE:"):
453
+ description = output.strip()[len("BOSS_ESCALATE:"):].strip()
454
+ logger.info(
455
+ "fix_engine: BOSS_ESCALATE for %s — dropping dev task: %s",
456
+ event.fingerprint, description[:200],
457
+ )
458
+ try:
459
+ from .sentinel_dev import drop_escalation
460
+ from pathlib import Path as _Path
461
+ project_dir = _Path(cfg.workspace_dir).parent
462
+ drop_escalation(
463
+ project_dir,
464
+ description,
465
+ source="fix_engine/BOSS_ESCALATE",
466
+ source_fingerprint=event.fingerprint,
467
+ )
468
+ except Exception as _esc_err:
469
+ logger.error("fix_engine: failed to drop escalation: %s", _esc_err)
470
+ return "skip", None, description
471
+
447
472
  patch = _extract_patch(output)
448
473
  if not patch:
449
474
  logger.warning("No patch found in Claude output for %s", event.fingerprint)
@@ -358,6 +358,21 @@ def publish(
358
358
  return branch, pr_url
359
359
 
360
360
 
361
+ def _alert_github_token_error(cfg: "SentinelConfig", owner_repo: str, status: int, hint: str = "") -> None:
362
+ """Fire a Slack alert when GitHub API rejects the token during PR creation."""
363
+ msg = (
364
+ f":warning: *Sentinel — GitHub token error ({status})*\n"
365
+ f"Could not open a PR for `{owner_repo}`.\n"
366
+ f"{hint}\n"
367
+ f"Check `GITHUB_TOKEN` in `sentinel.properties` — it may be expired, revoked, "
368
+ f"or not scoped to this org/repo."
369
+ )
370
+ logger.error("GitHub token error %s for %s: %s", status, owner_repo, hint)
371
+ if cfg.slack_bot_token and cfg.slack_channel:
372
+ from .notify import slack_alert
373
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
374
+
375
+
361
376
  def _open_github_pr(
362
377
  event: ErrorEvent,
363
378
  repo: RepoConfig,
@@ -407,10 +422,31 @@ def _open_github_pr(
407
422
  return pr_url
408
423
  else:
409
424
  logger.error("Failed to open PR (%s): %s", resp.status_code, resp.text[:300])
425
+ if resp.status_code in (401, 403, 404):
426
+ hint = "Token may lack access to this org/repo." if resp.status_code == 404 else resp.json().get("message", "")
427
+ _alert_github_token_error(cfg, owner_repo, resp.status_code, hint)
410
428
  return ""
411
429
 
412
430
 
413
- def poll_open_prs(store, github_token: str) -> list[dict]:
431
+ def _delete_github_branch(owner_repo: str, branch: str, headers: dict) -> None:
432
+ """Delete a remote branch from GitHub after its PR is merged or closed."""
433
+ try:
434
+ resp = requests.delete(
435
+ f"https://api.github.com/repos/{owner_repo}/git/refs/heads/{branch}",
436
+ headers=headers,
437
+ timeout=15,
438
+ )
439
+ if resp.status_code == 204:
440
+ logger.info("Deleted branch %s from %s", branch, owner_repo)
441
+ elif resp.status_code == 422:
442
+ logger.debug("Branch %s already deleted on %s", branch, owner_repo)
443
+ else:
444
+ logger.warning("Could not delete branch %s (%s): %s", branch, resp.status_code, resp.text[:200])
445
+ except Exception as e:
446
+ logger.warning("Failed to delete branch %s: %s", branch, e)
447
+
448
+
449
+ def poll_open_prs(store, github_token: str, cfg: "SentinelConfig | None" = None) -> list[dict]:
414
450
  """
415
451
  Check GitHub for the current state of all pending PRs in the state store.
416
452
 
@@ -463,6 +499,14 @@ def poll_open_prs(store, github_token: str) -> list[dict]:
463
499
  if resp.status_code == 404:
464
500
  logger.warning("poll_open_prs: PR not found (deleted?): %s", pr_url)
465
501
  continue
502
+ if resp.status_code in (401, 403):
503
+ logger.error(
504
+ "poll_open_prs: GitHub token rejected (%s) for %s — "
505
+ "org may require a fine-grained PAT. Set GITHUB_TOKEN in sentinel.properties.",
506
+ resp.status_code, pr_url,
507
+ )
508
+ _alert_github_token_error(cfg, pr_url, resp.status_code, resp.json().get("message", ""))
509
+ break # No point retrying other PRs with the same bad token
466
510
  if resp.status_code != 200:
467
511
  logger.warning("poll_open_prs: unexpected %s for %s", resp.status_code, pr_url)
468
512
  continue
@@ -475,6 +519,7 @@ def poll_open_prs(store, github_token: str) -> list[dict]:
475
519
  continue # still pending
476
520
 
477
521
  new_status = "merged" if merged else "skipped"
522
+ branch = fix.get("branch", "")
478
523
 
479
524
  with store._conn() as conn:
480
525
  conn.execute(
@@ -486,6 +531,11 @@ def poll_open_prs(store, github_token: str) -> list[dict]:
486
531
  "poll_open_prs: PR #%s %s → %s (fp=%s)",
487
532
  pr_number, owner_repo, new_status, fingerprint[:8],
488
533
  )
534
+
535
+ # Delete the fix branch from GitHub when PR is closed (merged or rejected)
536
+ if branch:
537
+ _delete_github_branch(owner_repo, branch, headers)
538
+
489
539
  changes.append({
490
540
  "fingerprint": fingerprint,
491
541
  "pr_url": pr_url,
@@ -52,7 +52,9 @@ class IssueEvent:
52
52
 
53
53
  def __post_init__(self):
54
54
  if not self.fingerprint:
55
- raw = f"issue:{self.source}:{self.message[:200]}"
55
+ # Use target_repo + message (not filename) so retries of the same
56
+ # issue share the same fingerprint — enables cooldown dedup.
57
+ raw = f"issue:{self.target_repo}:{self.message[:200]}"
56
58
  self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
57
59
  if not self.submitter_user_id:
58
60
  import re as _re