@misterhuydo/sentinel 1.4.89 → 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-27T05:41:33.700Z
1
+ 2026-03-27T13:47:13.440Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-27T05:38:58.308Z",
3
- "checkpoint_at": "2026-03-27T05:38:58.309Z",
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": {}
@@ -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/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.89",
3
+ "version": "1.4.91",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -75,6 +75,7 @@ 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
78
79
 
79
80
 
80
81
  @dataclass
@@ -151,14 +152,20 @@ class ConfigLoader:
151
152
  project_d = _parse_properties(str(path))
152
153
  d.update({k: v for k, v in project_d.items() if v})
153
154
 
154
- # If PRIVATE_SLACK=true, load Slack tokens from <projectDir>/slack.properties
155
- # (gitignored tokens never live in the config repo).
156
- 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":
157
164
  slack_props = self.config_dir.parent / "slack.properties"
158
165
  if slack_props.exists():
159
166
  slack_d = _parse_properties(str(slack_props))
160
167
  d.update({k: v for k, v in slack_d.items() if v})
161
- logger.debug("Loaded private Slack config from %s", slack_props)
168
+ logger.debug("Loaded private config from %s (legacy)", slack_props)
162
169
  else:
163
170
  logger.warning("PRIVATE_SLACK=true but %s not found — run `sentinel add` to create it", slack_props)
164
171
 
@@ -206,6 +213,7 @@ class ConfigLoader:
206
213
  c.sync_max_file_mb = int(d.get("SYNC_MAX_FILE_MB", 200))
207
214
  raw_mode = d.get("BOSS_MODE", "standard").lower().strip()
208
215
  c.boss_mode = raw_mode if raw_mode in ("standard", "strict", "fun") else "standard"
216
+ c.sentinel_dev_repo_path = d.get("SENTINEL_DEV_REPO_PATH", "")
209
217
  self.sentinel = c
210
218
 
211
219
  def _load_log_sources(self):
@@ -0,0 +1,288 @@
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
+ NOTIFY: U1234567,U7654321 # optional extra users to ping on completion
18
+
19
+ Task description — what to implement, fix, or improve in Sentinel.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import logging
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ import re
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _META_PREFIXES = ("TYPE:", "SUBMITTED_BY:", "SOURCE:", "SOURCE_FINGERPRINT:", "SUBMITTED_AT:", "NOTIFY:")
35
+
36
+
37
+ @dataclass
38
+ class DevTask:
39
+ source: str # "dev-tasks/<filename>"
40
+ task_file: Path # absolute path, used for archiving
41
+ message: str # first non-blank body line — shown in Slack
42
+ body: str # full task body (after headers stripped)
43
+ task_type: str # feature|fix|refactor|chore|ask
44
+ fingerprint: str = ""
45
+ timestamp: str = ""
46
+ submitter_user_id: str = ""
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
49
+
50
+ def __post_init__(self):
51
+ if not self.fingerprint:
52
+ raw = f"dev:{self.task_type}:{self.message[:200]}"
53
+ self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
54
+ if not self.timestamp:
55
+ self.timestamp = datetime.now(timezone.utc).isoformat()
56
+ if not self.submitter_user_id:
57
+ for line in self.body.splitlines():
58
+ m = re.match(r'SUBMITTED_BY:.*\(([UW][A-Z0-9]+)\)', line.strip())
59
+ if m:
60
+ self.submitter_user_id = m.group(1)
61
+ break
62
+
63
+
64
+ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
65
+ """Return all pending dev task files from <project_dir>/dev-tasks/."""
66
+ tasks_dir = project_dir / "dev-tasks"
67
+ tasks_dir.mkdir(exist_ok=True)
68
+
69
+ def _priority(p: Path) -> tuple:
70
+ if p.name.startswith("slack-"):
71
+ return (0, p.name)
72
+ if p.name.startswith("bot-"):
73
+ return (2, p.name)
74
+ return (1, p.name)
75
+
76
+ tasks = []
77
+ for f in sorted(tasks_dir.iterdir(), key=_priority):
78
+ if not f.is_file() or f.name.startswith("."):
79
+ continue
80
+ if f.suffix.lower() not in (".txt", ".md", ""):
81
+ continue
82
+ try:
83
+ content = f.read_text(encoding="utf-8", errors="replace").strip()
84
+ except OSError as e:
85
+ logger.error("Cannot read dev task %s: %s", f, e)
86
+ continue
87
+ if not content:
88
+ continue
89
+
90
+ lines = content.splitlines()
91
+ task_type = "feature"
92
+ source_fingerprint = ""
93
+ submitter_user_id = ""
94
+ notify_user_ids: list = []
95
+ body_start = 0
96
+
97
+ for i, line in enumerate(lines):
98
+ stripped = line.strip()
99
+ upper = stripped.upper()
100
+ if upper.startswith("TYPE:"):
101
+ raw = stripped[5:].strip().lower()
102
+ task_type = raw if raw in ("feature", "fix", "refactor", "chore", "ask") else "feature"
103
+ body_start = i + 1
104
+ elif upper.startswith("SUBMITTED_BY:"):
105
+ m = re.search(r'\(([UW][A-Z0-9]+)\)', stripped)
106
+ if m:
107
+ submitter_user_id = m.group(1)
108
+ body_start = i + 1
109
+ elif upper.startswith("SOURCE_FINGERPRINT:"):
110
+ source_fingerprint = stripped[19:].strip()
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
116
+ elif any(upper.startswith(p) for p in _META_PREFIXES) or not stripped:
117
+ body_start = i + 1
118
+ else:
119
+ break
120
+
121
+ body = "\n".join(lines[body_start:]).strip() or content
122
+ message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
123
+
124
+ tasks.append(DevTask(
125
+ source=f"dev-tasks/{f.name}",
126
+ task_file=f,
127
+ message=message,
128
+ body=body,
129
+ task_type=task_type,
130
+ submitter_user_id=submitter_user_id,
131
+ source_fingerprint=source_fingerprint,
132
+ notify_user_ids=notify_user_ids,
133
+ ))
134
+ logger.info("Found dev task: %s (type=%s)", f.name, task_type)
135
+
136
+ return tasks
137
+
138
+
139
+ def mark_dev_done(task_file: Path) -> None:
140
+ """Archive a processed dev task to dev-tasks/.done/ regardless of outcome."""
141
+ done_dir = task_file.parent / ".done"
142
+ done_dir.mkdir(exist_ok=True)
143
+ dest = done_dir / task_file.name
144
+ if dest.exists():
145
+ dest = done_dir / f"{task_file.stem}-{int(time.time())}{task_file.suffix}"
146
+ task_file.rename(dest)
147
+ logger.info("Dev task archived: %s -> .done/%s", task_file.name, dest.name)
148
+
149
+
150
+ _LOG_TIMESTAMP_RE = re.compile(
151
+ r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+ '
152
+ r'(ERROR|CRITICAL)\s+(\S+) — (.+)$'
153
+ )
154
+ _LOG_LINE_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+ \w')
155
+
156
+ # Error patterns in Sentinel's own code that are worth Dev Claude investigating.
157
+ # Excludes transient/external errors that Dev Claude cannot fix.
158
+ _SKIP_PATTERNS = re.compile(
159
+ r'SSH|Connection|Timeout|Rate.limit|npm show|git pull|git push'
160
+ r'|slack_bolt|urllib|requests\.|socket\.',
161
+ re.IGNORECASE,
162
+ )
163
+
164
+
165
+ def scan_sentinel_errors(
166
+ log_path: Path,
167
+ tail_lines: int = 300,
168
+ seen_fps: set | None = None,
169
+ ) -> list[tuple[str, str]]:
170
+ """
171
+ Scan Sentinel's own log file for recent ERROR/CRITICAL entries and
172
+ group them with their Traceback blocks.
173
+
174
+ Returns list of (fingerprint, error_text) for errors not in seen_fps.
175
+ Each error_text is suitable as a Dev Claude task body.
176
+ seen_fps is updated in-place so callers can track across polls.
177
+ """
178
+ if seen_fps is None:
179
+ seen_fps = set()
180
+
181
+ if not log_path.exists():
182
+ return []
183
+
184
+ try:
185
+ # Read last tail_lines lines efficiently
186
+ with open(log_path, encoding="utf-8", errors="replace") as f:
187
+ lines = f.readlines()
188
+ lines = lines[-tail_lines:]
189
+ except OSError:
190
+ return []
191
+
192
+ # Group lines into blocks: each block starts at an ERROR/CRITICAL log line
193
+ blocks: list[list[str]] = []
194
+ current: list[str] | None = None
195
+
196
+ for line in lines:
197
+ if _LOG_LINE_RE.match(line):
198
+ if current is not None:
199
+ blocks.append(current)
200
+ m = _LOG_TIMESTAMP_RE.match(line.rstrip())
201
+ current = [line.rstrip()] if m else None
202
+ elif current is not None:
203
+ current.append(line.rstrip())
204
+
205
+ if current:
206
+ blocks.append(current)
207
+
208
+ results = []
209
+ for block in blocks:
210
+ if not block:
211
+ continue
212
+ header = block[0]
213
+ m = _LOG_TIMESTAMP_RE.match(header)
214
+ if not m:
215
+ continue
216
+
217
+ level, logger_name, message = m.group(1), m.group(2), m.group(3)
218
+
219
+ # Skip errors from external systems that Dev Claude can't fix
220
+ if _SKIP_PATTERNS.search(message):
221
+ continue
222
+ # Skip if no traceback (single-line errors are often transient)
223
+ has_traceback = any("Traceback" in l or "Error:" in l for l in block[1:])
224
+ if not has_traceback and level != "CRITICAL":
225
+ continue
226
+
227
+ # Fingerprint: hash of normalized message + top traceback frame
228
+ top_frame = next(
229
+ (l.strip() for l in block if l.strip().startswith("File ")), ""
230
+ )
231
+ normalized = re.sub(r'0x[0-9a-f]+|\d+', 'N', message)
232
+ raw = f"sentinel-self:{logger_name}:{normalized}:{top_frame[:80]}"
233
+ fp = hashlib.sha1(raw.encode()).hexdigest()[:16]
234
+
235
+ if fp in seen_fps:
236
+ continue
237
+ seen_fps.add(fp)
238
+
239
+ error_text = "\n".join(block)
240
+ task_body = (
241
+ f"Sentinel detected an error in its own code (logger: {logger_name}).\n"
242
+ f"Investigate the root cause in the Sentinel source and fix it.\n\n"
243
+ f"LOG ENTRY:\n{error_text}\n"
244
+ )
245
+ results.append((fp, task_body))
246
+
247
+ return results
248
+
249
+
250
+ def drop_self_repair_task(project_dir: Path, fp: str, task_body: str) -> Path:
251
+ """
252
+ Create a self-repair dev task file for an error found in Sentinel's own log.
253
+ Returns the path to the created file.
254
+ """
255
+ dev_tasks_dir = project_dir / "dev-tasks"
256
+ dev_tasks_dir.mkdir(exist_ok=True)
257
+ ts = int(time.time())
258
+ fname = f"self-{fp[:8]}-{ts}.txt"
259
+ fpath = dev_tasks_dir / fname
260
+ lines = [
261
+ "TYPE: fix",
262
+ "SOURCE: self_repair",
263
+ f"SOURCE_FINGERPRINT: {fp}",
264
+ f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
265
+ "",
266
+ task_body,
267
+ ]
268
+ fpath.write_text("\n".join(lines), encoding="utf-8")
269
+ logger.info("Self-repair task dropped: %s", fname)
270
+ return fpath
271
+
272
+
273
+ def purge_old_dev_tasks(dev_tasks_dir: Path, keep_days: int = 30) -> int:
274
+ """Delete files older than keep_days from dev-tasks/.done/."""
275
+ cutoff = time.time() - keep_days * 86400
276
+ deleted = 0
277
+ done = dev_tasks_dir / ".done"
278
+ if done.exists():
279
+ for f in done.iterdir():
280
+ if f.is_file() and f.stat().st_mtime < cutoff:
281
+ try:
282
+ f.unlink()
283
+ deleted += 1
284
+ except OSError:
285
+ pass
286
+ if deleted:
287
+ logger.info("Purged %d old dev task archive file(s) (>%d days)", deleted, keep_days)
288
+ 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 Patch, the Sentinel dev 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)