@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 +1 -1
- package/.cairn/session.json +2 -2
- package/lib/.cairn/minify-map.json +8 -1
- package/lib/.cairn/views/ff8fde_test.js +172 -0
- package/lib/add.js +8 -20
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +12 -4
- package/python/sentinel/dev_watcher.py +288 -0
- package/python/sentinel/fix_engine.py +25 -0
- package/python/sentinel/git_manager.py +51 -1
- package/python/sentinel/main.py +268 -2
- package/python/sentinel/repo_task_engine.py +381 -0
- package/python/sentinel/sentinel_boss.py +373 -6
- package/python/sentinel/sentinel_dev.py +448 -0
- package/python/sentinel/state_store.py +121 -0
- package/templates/log-configs/_example.properties +21 -32
- package/templates/sentinel.properties +5 -6
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-27T13:47:13.440Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
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": {}
|
|
@@ -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
|
|
619
|
+
// Write private tokens to private_sentinel.properties (gitignored — never committed)
|
|
620
620
|
if (projectSlackBotToken || projectSlackAppToken) {
|
|
621
|
-
|
|
622
|
-
const
|
|
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(
|
|
627
|
-
ok('Private
|
|
625
|
+
fs.writeFileSync(privateProps, lines.join('\n') + '\n');
|
|
626
|
+
ok('Private tokens → private_sentinel.properties (local only)');
|
|
628
627
|
|
|
629
|
-
// Ensure
|
|
628
|
+
// Ensure private_sentinel.properties is gitignored
|
|
630
629
|
const gitignore = path.join(projectDir, '.gitignore');
|
|
631
|
-
const ignoreEntry = '
|
|
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 —
|
|
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
|
@@ -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
|
-
#
|
|
155
|
-
#
|
|
156
|
-
|
|
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
|
|
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)
|