@misterhuydo/sentinel 1.0.16 → 1.0.18
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/generate.js +1 -0
- package/lib/init.js +32 -6
- package/package.json +1 -1
- package/python/sentinel/sentinel_boss.py +356 -297
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-21T18:
|
|
1
|
+
2026-03-21T18:43:03.656Z
|
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-21T18:38:32.740Z",
|
|
3
|
+
"checkpoint_at": "2026-03-21T18:38:32.741Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/lib/generate.js
CHANGED
|
@@ -120,6 +120,7 @@ for project_dir in "$WORKSPACE"/*/; do
|
|
|
120
120
|
valid_repo=false
|
|
121
121
|
for props in "$project_dir/config/repo-configs/"*.properties; do
|
|
122
122
|
[[ -f "$props" ]] || continue
|
|
123
|
+
[[ "$(basename "$props")" == _* ]] && continue
|
|
123
124
|
if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
|
|
124
125
|
valid_repo=true
|
|
125
126
|
break
|
package/lib/init.js
CHANGED
|
@@ -14,6 +14,13 @@ const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
|
|
|
14
14
|
const step = msg => console.log('\n' + chalk.bold.white(msg));
|
|
15
15
|
|
|
16
16
|
module.exports = async function init() {
|
|
17
|
+
// Pre-read existing workspace config so prompts can show current values
|
|
18
|
+
const defaultWorkspace = path.join(os.homedir(), 'sentinel');
|
|
19
|
+
const existing = readExistingConfig(defaultWorkspace);
|
|
20
|
+
if (Object.keys(existing).length) {
|
|
21
|
+
console.log(chalk.cyan('\n → Existing workspace config found — showing current values as defaults\n'));
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
// ── Prompts ─────────────────────────────────────────────────────────────────
|
|
18
25
|
const answers = await prompts([
|
|
19
26
|
{
|
|
@@ -54,25 +61,25 @@ module.exports = async function init() {
|
|
|
54
61
|
{
|
|
55
62
|
type: 'text',
|
|
56
63
|
name: 'smtpUser',
|
|
57
|
-
message: 'SMTP sender address
|
|
58
|
-
initial: '',
|
|
64
|
+
message: 'SMTP sender address',
|
|
65
|
+
initial: existing.SMTP_USER || 'sentinel@yourdomain.com',
|
|
59
66
|
},
|
|
60
67
|
{
|
|
61
68
|
type: prev => prev ? 'password' : null,
|
|
62
69
|
name: 'smtpPassword',
|
|
63
|
-
message: 'SMTP password / app password',
|
|
70
|
+
message: existing.SMTP_PASSWORD ? 'SMTP password / app password (press Enter to keep current)' : 'SMTP password / app password',
|
|
64
71
|
},
|
|
65
72
|
{
|
|
66
73
|
type: prev => prev ? 'text' : null,
|
|
67
74
|
name: 'smtpHost',
|
|
68
75
|
message: 'SMTP host',
|
|
69
|
-
initial: 'smtp.gmail.com',
|
|
76
|
+
initial: existing.SMTP_HOST || 'smtp.gmail.com',
|
|
70
77
|
},
|
|
71
78
|
{
|
|
72
79
|
type: 'confirm',
|
|
73
80
|
name: 'setupSlack',
|
|
74
81
|
message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
|
|
75
|
-
initial:
|
|
82
|
+
initial: !!(existing.SLACK_BOT_TOKEN),
|
|
76
83
|
},
|
|
77
84
|
{
|
|
78
85
|
type: prev => prev ? 'password' : null,
|
|
@@ -165,7 +172,9 @@ module.exports = async function init() {
|
|
|
165
172
|
|
|
166
173
|
// ── Workspace start/stop scripts ─────────────────────────────────────────────
|
|
167
174
|
step('Generating scripts…');
|
|
168
|
-
|
|
175
|
+
// If user left password blank (pressed Enter to keep), fall back to existing
|
|
176
|
+
const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
|
|
177
|
+
generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword });
|
|
169
178
|
ok(`${workspace}/startAll.sh`);
|
|
170
179
|
ok(`${workspace}/stopAll.sh`);
|
|
171
180
|
|
|
@@ -207,6 +216,23 @@ module.exports = async function init() {
|
|
|
207
216
|
|
|
208
217
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
209
218
|
|
|
219
|
+
function readExistingConfig(workspace) {
|
|
220
|
+
const propsPath = path.join(workspace, 'sentinel.properties');
|
|
221
|
+
if (!fs.existsSync(propsPath)) return {};
|
|
222
|
+
const result = {};
|
|
223
|
+
try {
|
|
224
|
+
fs.readFileSync(propsPath, 'utf8').split('
|
|
225
|
+
').forEach(line => {
|
|
226
|
+
line = line.trim();
|
|
227
|
+
if (!line || line.startsWith('#')) return;
|
|
228
|
+
const idx = line.indexOf('=');
|
|
229
|
+
if (idx === -1) return;
|
|
230
|
+
result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
231
|
+
});
|
|
232
|
+
} catch (_) {}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
210
236
|
function findPython() {
|
|
211
237
|
for (const bin of ['python3', 'python']) {
|
|
212
238
|
try {
|
package/package.json
CHANGED
|
@@ -1,297 +1,356 @@
|
|
|
1
|
-
"""
|
|
2
|
-
sentinel_boss.py — Claude-backed Sentinel Boss.
|
|
3
|
-
|
|
4
|
-
Claude acts as the boss: reads project state, decides on actions,
|
|
5
|
-
executes them via tool use, and responds naturally. One agentic loop
|
|
6
|
-
per turn — Claude may call multiple tools before replying.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import logging
|
|
11
|
-
import os
|
|
12
|
-
import uuid
|
|
13
|
-
from datetime import datetime, timezone
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Optional
|
|
16
|
-
|
|
17
|
-
logger = logging.getLogger(__name__)
|
|
18
|
-
|
|
19
|
-
# ── System prompt ────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
_SYSTEM = """\
|
|
22
|
-
You are Sentinel Boss — the AI interface for Sentinel, a 24/7 autonomous DevOps agent.
|
|
23
|
-
|
|
24
|
-
Sentinel watches production logs, detects errors, generates code fixes via Claude Code,
|
|
25
|
-
and opens GitHub PRs for admin review (or pushes directly if AUTO_PUBLISH=true).
|
|
26
|
-
|
|
27
|
-
Your job:
|
|
28
|
-
- Understand what the DevOps engineer needs in natural language
|
|
29
|
-
- Query Sentinel's live state (errors, fixes, open PRs) on their behalf
|
|
30
|
-
- Create issue reports when asked to investigate or fix something
|
|
31
|
-
- Control Sentinel (pause/resume) when asked
|
|
32
|
-
- Give honest, concise answers — you know this system inside out
|
|
33
|
-
|
|
34
|
-
Tone: direct, professional, like a senior engineer who owns the system.
|
|
35
|
-
Don't pad responses. Don't say "Great question!" or "Certainly!".
|
|
36
|
-
If you don't know something, use a tool to find out before saying you don't know.
|
|
37
|
-
|
|
38
|
-
When the engineer's request is fully handled, end your LAST message with the token: [DONE]
|
|
39
|
-
If you need a follow-up from them, do NOT include [DONE] — wait for their next message.
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
# ── Tool definitions ─────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
_TOOLS = [
|
|
45
|
-
{
|
|
46
|
-
"name": "get_status",
|
|
47
|
-
"description": (
|
|
48
|
-
"Get recent errors, fixes applied, fixes pending review, and open PRs. "
|
|
49
|
-
"Use for: 'what happened today?', 'any issues?', 'how are things?', "
|
|
50
|
-
"'what are the open PRs?', 'did sentinel fix anything?'"
|
|
51
|
-
),
|
|
52
|
-
"input_schema": {
|
|
53
|
-
"type": "object",
|
|
54
|
-
"properties": {
|
|
55
|
-
"hours": {
|
|
56
|
-
"type": "integer",
|
|
57
|
-
"description": "Look-back window in hours (default 24)",
|
|
58
|
-
"default": 24,
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
"name": "create_issue",
|
|
65
|
-
"description": (
|
|
66
|
-
"Queue a fix request for Sentinel to investigate on the next poll cycle. "
|
|
67
|
-
"Use whenever the engineer reports a bug, customer complaint, or asks you "
|
|
68
|
-
"to look into something specific. Include every detail they gave you."
|
|
69
|
-
),
|
|
70
|
-
"input_schema": {
|
|
71
|
-
"type": "object",
|
|
72
|
-
"properties": {
|
|
73
|
-
"description": {
|
|
74
|
-
"type": "string",
|
|
75
|
-
"description": "Full problem description — everything the engineer told you",
|
|
76
|
-
},
|
|
77
|
-
"target_repo": {
|
|
78
|
-
"type": "string",
|
|
79
|
-
"description": "Repo name to assign to (omit to let Sentinel auto-route)",
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
"required": ["description"],
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
"name": "get_fix_details",
|
|
87
|
-
"description": "Get full details of a specific fix by fingerprint (8+ hex chars).",
|
|
88
|
-
"input_schema": {
|
|
89
|
-
"type": "object",
|
|
90
|
-
"properties": {
|
|
91
|
-
"fingerprint": {"type": "string"},
|
|
92
|
-
},
|
|
93
|
-
"required": ["fingerprint"],
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
"name": "list_pending_prs",
|
|
98
|
-
"description": "List all open Sentinel PRs awaiting admin review.",
|
|
99
|
-
"input_schema": {"type": "object", "properties": {}},
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
"name": "pause_sentinel",
|
|
103
|
-
"description": (
|
|
104
|
-
"Pause ALL Sentinel fix activity immediately. "
|
|
105
|
-
"Use when the engineer says 'pause', 'stop', 'freeze', or 'hold off'."
|
|
106
|
-
),
|
|
107
|
-
"input_schema": {"type": "object", "properties": {}},
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
"name": "resume_sentinel",
|
|
111
|
-
"description": "Resume Sentinel fix activity after a pause.",
|
|
112
|
-
"input_schema": {"type": "object", "properties": {}},
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
],
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if name == "
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
1
|
+
"""
|
|
2
|
+
sentinel_boss.py — Claude-backed Sentinel Boss.
|
|
3
|
+
|
|
4
|
+
Claude acts as the boss: reads project state, decides on actions,
|
|
5
|
+
executes them via tool use, and responds naturally. One agentic loop
|
|
6
|
+
per turn — Claude may call multiple tools before replying.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# ── System prompt ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
_SYSTEM = """\
|
|
22
|
+
You are Sentinel Boss — the AI interface for Sentinel, a 24/7 autonomous DevOps agent.
|
|
23
|
+
|
|
24
|
+
Sentinel watches production logs, detects errors, generates code fixes via Claude Code,
|
|
25
|
+
and opens GitHub PRs for admin review (or pushes directly if AUTO_PUBLISH=true).
|
|
26
|
+
|
|
27
|
+
Your job:
|
|
28
|
+
- Understand what the DevOps engineer needs in natural language
|
|
29
|
+
- Query Sentinel's live state (errors, fixes, open PRs) on their behalf
|
|
30
|
+
- Create issue reports when asked to investigate or fix something
|
|
31
|
+
- Control Sentinel (pause/resume) when asked
|
|
32
|
+
- Give honest, concise answers — you know this system inside out
|
|
33
|
+
|
|
34
|
+
Tone: direct, professional, like a senior engineer who owns the system.
|
|
35
|
+
Don't pad responses. Don't say "Great question!" or "Certainly!".
|
|
36
|
+
If you don't know something, use a tool to find out before saying you don't know.
|
|
37
|
+
|
|
38
|
+
When the engineer's request is fully handled, end your LAST message with the token: [DONE]
|
|
39
|
+
If you need a follow-up from them, do NOT include [DONE] — wait for their next message.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# ── Tool definitions ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
_TOOLS = [
|
|
45
|
+
{
|
|
46
|
+
"name": "get_status",
|
|
47
|
+
"description": (
|
|
48
|
+
"Get recent errors, fixes applied, fixes pending review, and open PRs. "
|
|
49
|
+
"Use for: 'what happened today?', 'any issues?', 'how are things?', "
|
|
50
|
+
"'what are the open PRs?', 'did sentinel fix anything?'"
|
|
51
|
+
),
|
|
52
|
+
"input_schema": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {
|
|
55
|
+
"hours": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"description": "Look-back window in hours (default 24)",
|
|
58
|
+
"default": 24,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "create_issue",
|
|
65
|
+
"description": (
|
|
66
|
+
"Queue a fix request for Sentinel to investigate on the next poll cycle. "
|
|
67
|
+
"Use whenever the engineer reports a bug, customer complaint, or asks you "
|
|
68
|
+
"to look into something specific. Include every detail they gave you."
|
|
69
|
+
),
|
|
70
|
+
"input_schema": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"description": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "Full problem description — everything the engineer told you",
|
|
76
|
+
},
|
|
77
|
+
"target_repo": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Repo name to assign to (omit to let Sentinel auto-route)",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
"required": ["description"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "get_fix_details",
|
|
87
|
+
"description": "Get full details of a specific fix by fingerprint (8+ hex chars).",
|
|
88
|
+
"input_schema": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"fingerprint": {"type": "string"},
|
|
92
|
+
},
|
|
93
|
+
"required": ["fingerprint"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "list_pending_prs",
|
|
98
|
+
"description": "List all open Sentinel PRs awaiting admin review.",
|
|
99
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "pause_sentinel",
|
|
103
|
+
"description": (
|
|
104
|
+
"Pause ALL Sentinel fix activity immediately. "
|
|
105
|
+
"Use when the engineer says 'pause', 'stop', 'freeze', or 'hold off'."
|
|
106
|
+
),
|
|
107
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "resume_sentinel",
|
|
111
|
+
"description": "Resume Sentinel fix activity after a pause.",
|
|
112
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "list_projects",
|
|
116
|
+
"description": (
|
|
117
|
+
"List all projects (Sentinel instances) in this workspace and the repos "
|
|
118
|
+
"each one manages. Use for: 'what projects do you manage?', 'list projects', "
|
|
119
|
+
"'what repos are configured?', 'show me all projects'."
|
|
120
|
+
),
|
|
121
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── Tool execution ────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def _run_tool(name: str, inputs: dict, cfg_loader, store) -> str:
|
|
129
|
+
if name == "get_status":
|
|
130
|
+
hours = int(inputs.get("hours", 24))
|
|
131
|
+
errors = store.get_recent_errors(hours)
|
|
132
|
+
fixes = store.get_recent_fixes(hours)
|
|
133
|
+
prs = store.get_open_prs()
|
|
134
|
+
top_errors = [
|
|
135
|
+
{
|
|
136
|
+
"message": e["message"][:120],
|
|
137
|
+
"count": e["count"],
|
|
138
|
+
"source": e["source"],
|
|
139
|
+
"last_seen": e["last_seen"],
|
|
140
|
+
}
|
|
141
|
+
for e in errors[:8]
|
|
142
|
+
]
|
|
143
|
+
return json.dumps({
|
|
144
|
+
"window_hours": hours,
|
|
145
|
+
"errors_detected": len(errors),
|
|
146
|
+
"top_errors": top_errors,
|
|
147
|
+
"fixes_applied": sum(1 for f in fixes if f["status"] == "applied"),
|
|
148
|
+
"fixes_pending": sum(1 for f in fixes if f["status"] == "pending"),
|
|
149
|
+
"fixes_failed": sum(1 for f in fixes if f["status"] == "failed"),
|
|
150
|
+
"open_prs": [
|
|
151
|
+
{
|
|
152
|
+
"repo": p["repo_name"],
|
|
153
|
+
"branch": p["branch"],
|
|
154
|
+
"pr_url": p["pr_url"],
|
|
155
|
+
"age": p.get("timestamp", ""),
|
|
156
|
+
}
|
|
157
|
+
for p in prs
|
|
158
|
+
],
|
|
159
|
+
"sentinel_paused": Path("SENTINEL_PAUSE").exists(),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if name == "create_issue":
|
|
163
|
+
description = inputs["description"]
|
|
164
|
+
target_repo = inputs.get("target_repo", "")
|
|
165
|
+
issues_dir = Path("issues")
|
|
166
|
+
issues_dir.mkdir(exist_ok=True)
|
|
167
|
+
fname = f"slack-{uuid.uuid4().hex[:8]}.txt"
|
|
168
|
+
content = (f"TARGET_REPO: {target_repo}\n\n" if target_repo else "") + description
|
|
169
|
+
(issues_dir / fname).write_text(content, encoding="utf-8")
|
|
170
|
+
logger.info("Boss created issue: %s", fname)
|
|
171
|
+
return json.dumps({
|
|
172
|
+
"status": "queued",
|
|
173
|
+
"file": fname,
|
|
174
|
+
"note": "Sentinel will pick this up on the next poll cycle.",
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
if name == "get_fix_details":
|
|
178
|
+
fp = inputs["fingerprint"]
|
|
179
|
+
fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
|
|
180
|
+
if not fix:
|
|
181
|
+
# Fallback: search recent fixes by prefix
|
|
182
|
+
recent = store.get_recent_fixes(hours=72)
|
|
183
|
+
fix = next((f for f in recent if f.get("fingerprint", "").startswith(fp)), None)
|
|
184
|
+
return json.dumps(fix or {"error": "not found"})
|
|
185
|
+
|
|
186
|
+
if name == "list_pending_prs":
|
|
187
|
+
prs = store.get_open_prs()
|
|
188
|
+
return json.dumps({
|
|
189
|
+
"count": len(prs),
|
|
190
|
+
"open_prs": [
|
|
191
|
+
{
|
|
192
|
+
"repo": p["repo_name"],
|
|
193
|
+
"branch": p["branch"],
|
|
194
|
+
"pr_url": p["pr_url"],
|
|
195
|
+
"timestamp": p.get("timestamp", ""),
|
|
196
|
+
}
|
|
197
|
+
for p in prs
|
|
198
|
+
],
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if name == "pause_sentinel":
|
|
202
|
+
Path("SENTINEL_PAUSE").touch()
|
|
203
|
+
logger.info("Boss: SENTINEL_PAUSE created")
|
|
204
|
+
return json.dumps({"status": "paused"})
|
|
205
|
+
|
|
206
|
+
if name == "resume_sentinel":
|
|
207
|
+
p = Path("SENTINEL_PAUSE")
|
|
208
|
+
if p.exists():
|
|
209
|
+
p.unlink()
|
|
210
|
+
logger.info("Boss: SENTINEL_PAUSE removed")
|
|
211
|
+
return json.dumps({"status": "resumed"})
|
|
212
|
+
|
|
213
|
+
if name == "list_projects":
|
|
214
|
+
# Repos this instance manages
|
|
215
|
+
my_repos = [
|
|
216
|
+
{
|
|
217
|
+
"repo": r.repo_name,
|
|
218
|
+
"url": r.repo_url,
|
|
219
|
+
"branch": r.branch,
|
|
220
|
+
"auto_publish": r.auto_publish,
|
|
221
|
+
}
|
|
222
|
+
for r in cfg_loader.repos.values()
|
|
223
|
+
]
|
|
224
|
+
# Scan workspace for sibling project instances
|
|
225
|
+
workspace = Path(".").resolve().parent
|
|
226
|
+
other_projects = []
|
|
227
|
+
try:
|
|
228
|
+
for d in sorted(workspace.iterdir()):
|
|
229
|
+
if not d.is_dir() or d.name in ("code", ".git"):
|
|
230
|
+
continue
|
|
231
|
+
repo_cfg_dir = d / "config" / "repo-configs"
|
|
232
|
+
if not repo_cfg_dir.exists():
|
|
233
|
+
continue
|
|
234
|
+
repos_in_project = []
|
|
235
|
+
for p in sorted(repo_cfg_dir.glob("*.properties")):
|
|
236
|
+
if p.name.startswith("_"):
|
|
237
|
+
continue
|
|
238
|
+
repo_url = ""
|
|
239
|
+
for line in p.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
240
|
+
if line.startswith("REPO_URL"):
|
|
241
|
+
repo_url = line.split("=", 1)[-1].strip()
|
|
242
|
+
break
|
|
243
|
+
if repo_url:
|
|
244
|
+
repos_in_project.append({"repo": p.stem, "url": repo_url})
|
|
245
|
+
if repos_in_project:
|
|
246
|
+
pid_file = d / "sentinel.pid"
|
|
247
|
+
running = pid_file.exists()
|
|
248
|
+
other_projects.append({
|
|
249
|
+
"project": d.name,
|
|
250
|
+
"running": running,
|
|
251
|
+
"repos": repos_in_project,
|
|
252
|
+
})
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.warning("list_projects workspace scan failed: %s", e)
|
|
255
|
+
return json.dumps({
|
|
256
|
+
"this_instance": {
|
|
257
|
+
"project": Path(".").resolve().name,
|
|
258
|
+
"repos": my_repos,
|
|
259
|
+
},
|
|
260
|
+
"workspace_projects": other_projects,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
return json.dumps({"error": f"unknown tool: {name}"})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── Main entry point ──────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
async def handle_message(
|
|
269
|
+
message: str,
|
|
270
|
+
history: list,
|
|
271
|
+
cfg_loader,
|
|
272
|
+
store,
|
|
273
|
+
) -> tuple[str, bool]:
|
|
274
|
+
"""
|
|
275
|
+
Process one user message through the Sentinel Boss (Claude with tool use).
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
message: The user's Slack message text.
|
|
279
|
+
history: Conversation history list — mutated in place (role/content dicts).
|
|
280
|
+
cfg_loader: ConfigLoader for repo/sentinel config.
|
|
281
|
+
store: StateStore for DB queries.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
(reply_text, is_done)
|
|
285
|
+
is_done=True → session complete, release the Slack queue slot.
|
|
286
|
+
is_done=False → waiting for user follow-up, keep the slot.
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
import anthropic
|
|
290
|
+
except ImportError:
|
|
291
|
+
return (
|
|
292
|
+
":warning: `anthropic` package not installed. Run: `pip install anthropic`",
|
|
293
|
+
True,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
297
|
+
if not api_key:
|
|
298
|
+
return (
|
|
299
|
+
":warning: `ANTHROPIC_API_KEY` not configured in `sentinel.properties`.",
|
|
300
|
+
True,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
304
|
+
|
|
305
|
+
# Build system context snapshot
|
|
306
|
+
paused = Path("SENTINEL_PAUSE").exists()
|
|
307
|
+
repos = list(cfg_loader.repos.keys())
|
|
308
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
309
|
+
system = (
|
|
310
|
+
_SYSTEM
|
|
311
|
+
+ f"\n\nCurrent time: {ts}"
|
|
312
|
+
+ f"\nSentinel status: {'⏸ PAUSED' if paused else '▶ RUNNING'}"
|
|
313
|
+
+ f"\nManaged repos: {', '.join(repos) if repos else '(none configured)'}"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
history.append({"role": "user", "content": message})
|
|
317
|
+
messages = list(history)
|
|
318
|
+
|
|
319
|
+
# Agentic loop — Claude may call multiple tools before giving a final reply
|
|
320
|
+
while True:
|
|
321
|
+
response = client.messages.create(
|
|
322
|
+
model="claude-opus-4-6",
|
|
323
|
+
max_tokens=1024,
|
|
324
|
+
system=system,
|
|
325
|
+
tools=_TOOLS,
|
|
326
|
+
messages=messages,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
text_parts = []
|
|
330
|
+
tool_blocks = []
|
|
331
|
+
for block in response.content:
|
|
332
|
+
if block.type == "text":
|
|
333
|
+
text_parts.append(block.text)
|
|
334
|
+
elif block.type == "tool_use":
|
|
335
|
+
tool_blocks.append(block)
|
|
336
|
+
|
|
337
|
+
if not tool_blocks:
|
|
338
|
+
# Final response — no more tool calls
|
|
339
|
+
reply = " ".join(text_parts).strip()
|
|
340
|
+
is_done = "[DONE]" in reply
|
|
341
|
+
reply = reply.replace("[DONE]", "").strip()
|
|
342
|
+
history.append({"role": "assistant", "content": response.content})
|
|
343
|
+
return reply, is_done
|
|
344
|
+
|
|
345
|
+
# Execute tools and continue
|
|
346
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
347
|
+
tool_results = []
|
|
348
|
+
for tc in tool_blocks:
|
|
349
|
+
result = _run_tool(tc.name, tc.input, cfg_loader, store)
|
|
350
|
+
logger.info("Boss tool: %s(%s) → %s", tc.name, tc.input, result[:120])
|
|
351
|
+
tool_results.append({
|
|
352
|
+
"type": "tool_result",
|
|
353
|
+
"tool_use_id": tc.id,
|
|
354
|
+
"content": result,
|
|
355
|
+
})
|
|
356
|
+
messages.append({"role": "user", "content": tool_results})
|