@misterhuydo/sentinel 1.4.50 → 1.4.52

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-25T11:24:19.838Z",
3
- "checkpoint_at": "2026-03-25T11:24:19.839Z",
2
+ "message": "Auto-checkpoint at 2026-03-25T11:44:18.256Z",
3
+ "checkpoint_at": "2026-03-25T11:44:18.257Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
@@ -0,0 +1,372 @@
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 prompts = require('prompts');
7
+ const chalk = require('chalk');
8
+ const { generateProjectScripts, generateWorkspaceScripts, writeExampleProject } = require('./generate');
9
+ const ok = msg => console.log(chalk.green(' ✔'), msg);
10
+ const info = msg => console.log(chalk.cyan(' →'), msg);
11
+ const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
12
+ const step = msg => console.log('\n' + chalk.bold.white(msg));
13
+ module.exports = async function init() {
14
+ const defaultWorkspace = path.join(os.homedir(), 'sentinel');
15
+ const existing = readExistingConfig(defaultWorkspace);
16
+ if (Object.keys(existing).length) {
17
+ console.log(chalk.cyan('\n → Existing workspace config found — showing current values as defaults\n'));
18
+ }
19
+ const answers = await prompts([
20
+ {
21
+ type: 'text',
22
+ name: 'workspace',
23
+ message: 'Workspace directory (each project lives here as a subdirectory)',
24
+ initial: path.join(os.homedir(), 'sentinel'),
25
+ format: v => v.replace(/^~/, os.homedir()),
26
+ },
27
+ {
28
+ type: 'select',
29
+ name: 'authMode',
30
+ message: 'Claude authentication strategy',
31
+ hint: 'Boss = Slack AI; Fix Engine = autonomous code repair',
32
+ choices: [
33
+ {
34
+ title: 'Both (RECOMMENDED) — API key for Boss, Claude Pro for Fix Engine',
35
+ value: 'both',
36
+ },
37
+ {
38
+ title: 'API key only — full Boss tools; Fix Engine billed per token',
39
+ value: 'apikey',
40
+ },
41
+ {
42
+ title: 'Claude Pro / OAuth only — run `claude login`; Boss has limited tools',
43
+ value: 'oauth',
44
+ },
45
+ { title: 'Skip (configure manually in sentinel.properties)', value: 'skip' },
46
+ ],
47
+ },
48
+ {
49
+ type: prev => (prev === 'apikey' || prev === 'both') ? 'password' : null,
50
+ name: 'anthropicKey',
51
+ message: existing.ANTHROPIC_API_KEY
52
+ ? 'Anthropic API key (press Enter to keep current)'
53
+ : 'Anthropic API key (sk-ant-...)',
54
+ validate: v => !v || v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
55
+ },
56
+ {
57
+ type: 'confirm',
58
+ name: 'example',
59
+ message: 'Create an example project to show how to configure?',
60
+ initial: true,
61
+ },
62
+ {
63
+ type: 'confirm',
64
+ name: 'systemd',
65
+ message: 'Set up systemd service for auto-start on reboot?',
66
+ initial: process.platform === 'linux',
67
+ },
68
+ {
69
+ type: 'text',
70
+ name: 'smtpUser',
71
+ message: 'SMTP sender address',
72
+ initial: existing.SMTP_USER || 'sentinel@yourdomain.com',
73
+ },
74
+ {
75
+ type: prev => prev ? 'password' : null,
76
+ name: 'smtpPassword',
77
+ message: existing.SMTP_PASSWORD ? 'SMTP password / app password (press Enter to keep current)' : 'SMTP password / app password',
78
+ },
79
+ {
80
+ type: prev => prev ? 'text' : null,
81
+ name: 'smtpHost',
82
+ message: 'SMTP host',
83
+ initial: existing.SMTP_HOST || 'smtp.gmail.com',
84
+ },
85
+ {
86
+ type: 'confirm',
87
+ name: 'setupSlack',
88
+ message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
89
+ initial: !!(existing.SLACK_BOT_TOKEN),
90
+ },
91
+ {
92
+ type: prev => prev ? 'password' : null,
93
+ name: 'slackBotToken',
94
+ message: existing.SLACK_BOT_TOKEN ? 'Slack Bot Token (press Enter to keep current)' : 'Slack Bot Token (xoxb-...)',
95
+ validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
96
+ },
97
+ {
98
+ type: (_, { setupSlack }) => setupSlack ? 'password' : null,
99
+ name: 'slackAppToken',
100
+ message: existing.SLACK_APP_TOKEN ? 'Slack App-Level Token (press Enter to keep current)' : 'Slack App-Level Token (xapp-...)',
101
+ validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
102
+ },
103
+ ], { onCancel: () => process.exit(0) });
104
+ const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
105
+ const effectiveAnthropicKey = anthropicKey || existing.ANTHROPIC_API_KEY || '';
106
+ const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
107
+ const effectiveSlackBotToken = slackBotToken || existing.SLACK_BOT_TOKEN || '';
108
+ const effectiveSlackAppToken = slackAppToken || existing.SLACK_APP_TOKEN || '';
109
+ const codeDir = path.join(workspace, 'code');
110
+ step('Checking npm permissions…');
111
+ ensureNpmUserPrefix();
112
+ step('Checking Python…');
113
+ const python = findPython();
114
+ if (!python) {
115
+ console.error(chalk.red(' ✖ python3 not found. Install it first:'));
116
+ console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
117
+ process.exit(1);
118
+ }
119
+ ok(`Python: ${run(python, ['--version']).trim()}`);
120
+ step('Installing Sentinel code…');
121
+ const bundledPython = path.join(__dirname, '..', 'python');
122
+ if (!fs.existsSync(bundledPython)) {
123
+ console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
124
+ process.exit(1);
125
+ }
126
+ fs.ensureDirSync(codeDir);
127
+ fs.copySync(bundledPython, codeDir, { overwrite: true });
128
+ ok(`Sentinel code → ${codeDir}`);
129
+ step('Setting up Python environment…');
130
+ const venv = path.join(codeDir, '.venv');
131
+ if (!fs.existsSync(venv)) {
132
+ info('Creating virtual environment…');
133
+ runLive(python, ['-m', 'venv', venv]);
134
+ }
135
+ const pip = path.join(venv, 'bin', 'pip3');
136
+ const pythonBin = path.join(venv, 'bin', 'python3');
137
+ info('Installing Python packages…');
138
+ runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
139
+ runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
140
+ ok('Python packages installed');
141
+ step('Installing Node tools…');
142
+ installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
143
+ step('Hooking Cairn MCP into Claude Code…');
144
+ runLive('cairn', ['install']);
145
+ ok('cairn install complete');
146
+ installNpmGlobal('@anthropic-ai/claude-code', 'claude');
147
+ step('Patching Claude Code permissions…');
148
+ ensureClaudePermissions();
149
+ step('Claude Code authentication…');
150
+ if (authMode === 'both' && effectiveAnthropicKey) {
151
+ ok('API key → Sentinel Boss (full tools, structured responses)');
152
+ ok('Claude Pro → Fix Engine + Ask Codebase (heavy coding, Pro subscription)');
153
+ info('Run `claude login` on this server now (or before starting projects)');
154
+ info('CLAUDE_PRO_FOR_TASKS=true written to workspace sentinel.properties');
155
+ } else if (authMode === 'apikey' && effectiveAnthropicKey) {
156
+ ok('API key → all Claude usage (Boss + Fix Engine billed to your API quota)');
157
+ info('CLAUDE_PRO_FOR_TASKS=false written — Fix Engine will use API key');
158
+ warn('Heavy fix tasks will consume API tokens. Claude Pro is cheaper for those.');
159
+ } else if (authMode === 'oauth') {
160
+ ok('Claude Pro / OAuth → Fix Engine + Ask Codebase');
161
+ warn('Boss will use CLI fallback — some tools unavailable without an API key');
162
+ info('Run `claude login` on this server to authenticate');
163
+ } else {
164
+ warn('No auth configured — add ANTHROPIC_API_KEY or run `claude login` before starting');
165
+ info('See: workspace sentinel.properties for full auth documentation');
166
+ }
167
+ if (effectiveSlackBotToken && effectiveSlackAppToken) {
168
+ step('Slack Bot (Sentinel Boss)…');
169
+ ok('Tokens will be written to workspace sentinel.properties');
170
+ info('Sentinel Boss starts automatically when the project starts');
171
+ } else if (setupSlack) {
172
+ warn('Slack tokens not provided — add them to config/sentinel.properties later');
173
+ }
174
+ step('Creating workspace…');
175
+ fs.ensureDirSync(workspace);
176
+ ok(`Workspace: ${workspace}`);
177
+ if (example) {
178
+ step('Creating example project…');
179
+ const exampleDir = path.join(workspace, 'my-project');
180
+ writeExampleProject(exampleDir, codeDir, pythonBin, effectiveAnthropicKey, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
181
+ ok(`Example project: ${exampleDir}`);
182
+ }
183
+ step('Generating scripts…');
184
+ const authConfig = {};
185
+ if (effectiveAnthropicKey) authConfig.apiKey = effectiveAnthropicKey;
186
+ if (authMode === 'both' || authMode === 'oauth') authConfig.claudeProForTasks = true;
187
+ if (authMode === 'apikey') authConfig.claudeProForTasks = false;
188
+ generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken }, authConfig);
189
+ ok(`${workspace}/startAll.sh`);
190
+ ok(`${workspace}/stopAll.sh`);
191
+ if (systemd) {
192
+ step('Setting up systemd…');
193
+ setupSystemd(workspace);
194
+ }
195
+ console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
196
+ console.log(`${chalk.bold('Next steps:')}`);
197
+ if (example) {
198
+ console.log(`
199
+ 1. Configure your first project:
200
+ ${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
201
+ ${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
202
+ ${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
203
+ 2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
204
+ ${chalk.cyan(`${workspace}/startAll.sh`)}
205
+ 3. Stop all projects:
206
+ ${chalk.cyan(`${workspace}/stopAll.sh`)}
207
+ `);
208
+ }
209
+ if (systemd) {
210
+ console.log(` Auto-start is enabled. To manage:
211
+ ${chalk.cyan('sudo systemctl start sentinel')}
212
+ ${chalk.cyan('sudo systemctl status sentinel')}
213
+ ${chalk.cyan('journalctl -u sentinel -f')}
214
+ `);
215
+ }
216
+ console.log(` Add another project anytime:
217
+ ${chalk.cyan('sentinel add <project-name>')}
218
+ `);
219
+ };
220
+ function readExistingConfig(workspace) {
221
+ const result = {};
222
+ _parsePropsInto(path.join(workspace, 'sentinel.properties'), result);
223
+ if (!result.SLACK_BOT_TOKEN || !result.SLACK_APP_TOKEN) {
224
+ try {
225
+ for (const entry of fs.readdirSync(workspace)) {
226
+ const p = path.join(workspace, entry, 'config', 'sentinel.properties');
227
+ const proj = {};
228
+ _parsePropsInto(p, proj);
229
+ if (!result.SLACK_BOT_TOKEN && proj.SLACK_BOT_TOKEN) result.SLACK_BOT_TOKEN = proj.SLACK_BOT_TOKEN;
230
+ if (!result.SLACK_APP_TOKEN && proj.SLACK_APP_TOKEN) result.SLACK_APP_TOKEN = proj.SLACK_APP_TOKEN;
231
+ if (result.SLACK_BOT_TOKEN && result.SLACK_APP_TOKEN) break;
232
+ }
233
+ } catch (_) {}
234
+ }
235
+ return result;
236
+ }
237
+ function _parsePropsInto(propsPath, result) {
238
+ if (!fs.existsSync(propsPath)) return;
239
+ try {
240
+ const lines = fs.readFileSync(propsPath, 'utf8').split(/\r?\n/);
241
+ for (const raw of lines) {
242
+ const line = raw.trim();
243
+ if (!line || line.startsWith('#')) continue;
244
+ const idx = line.indexOf('=');
245
+ if (idx === -1) continue;
246
+ result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
247
+ }
248
+ } catch (_) {}
249
+ }
250
+ function findPython() {
251
+ for (const bin of ['python3', 'python']) {
252
+ try {
253
+ execSync(`${bin} --version`, { stdio: 'pipe' });
254
+ return bin;
255
+ } catch (_) {}
256
+ }
257
+ return null;
258
+ }
259
+ function run(bin, args) {
260
+ const r = spawnSync(bin, args, { encoding: 'utf8' });
261
+ return (r.stdout || '') + (r.stderr || '');
262
+ }
263
+ function runLive(bin, args) {
264
+ const r = spawnSync(bin, args, { stdio: 'inherit' });
265
+ if (r.status !== 0) {
266
+ console.error(chalk.red(` ✖ Command failed: ${bin} ${args.join(' ')}`));
267
+ process.exit(1);
268
+ }
269
+ }
270
+ function installNpmGlobal(pkg, checkBin) {
271
+ try {
272
+ execSync(`${checkBin} --version`, { stdio: 'pipe' });
273
+ ok(`${pkg} already installed`);
274
+ } catch (_) {
275
+ info(`Installing ${pkg}…`);
276
+ runLive('npm', ['install', '-g', pkg]);
277
+ ok(`${pkg} installed`);
278
+ }
279
+ }
280
+ function ensureClaudePermissions() {
281
+ const settingsPath = require('path').join(require('os').homedir(), '.claude', 'settings.json');
282
+ const required = ['Read(**)', 'Write(**)', 'Edit(**)', 'Bash(**)'];
283
+ let settings = {};
284
+ try {
285
+ if (fs.existsSync(settingsPath)) {
286
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
287
+ }
288
+ } catch (e) {
289
+ warn('Could not read ' + settingsPath + ': ' + e.message);
290
+ return;
291
+ }
292
+ if (!settings.permissions) settings.permissions = {};
293
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
294
+ const existing = new Set(settings.permissions.allow);
295
+ const added = [];
296
+ for (const perm of required) {
297
+ if (!existing.has(perm)) {
298
+ settings.permissions.allow.push(perm);
299
+ added.push(perm);
300
+ }
301
+ }
302
+ if (added.length === 0) {
303
+ ok('Claude Code permissions already configured');
304
+ return;
305
+ }
306
+ try {
307
+ fs.ensureDirSync(require('path').dirname(settingsPath));
308
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
309
+ ok('Claude Code permissions patched: ' + added.join(', '));
310
+ } catch (e) {
311
+ warn('Could not write ' + settingsPath + ': ' + e.message);
312
+ }
313
+ }
314
+ function ensureNpmUserPrefix() {
315
+ try {
316
+ const currentPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
317
+ const homeDir = os.homedir();
318
+ if (currentPrefix.startsWith(homeDir)) {
319
+ ok(`npm prefix: ${currentPrefix} (user-owned, no sudo needed)`);
320
+ return;
321
+ }
322
+ try { fs.accessSync(currentPrefix, fs.constants.W_OK); ok('npm prefix writable'); return; }
323
+ catch (_) {}
324
+ const userPrefix = path.join(homeDir, '.npm-global');
325
+ fs.ensureDirSync(userPrefix);
326
+ execSync(`npm config set prefix "${userPrefix}"`, { encoding: 'utf8' });
327
+ ok(`npm prefix set to ${userPrefix} (no sudo needed for upgrades)`);
328
+ const exportLine = `export PATH="${userPrefix}/bin:$PATH"`;
329
+ for (const rc of ['.bashrc', '.profile', '.zshrc']) {
330
+ const rcPath = path.join(homeDir, rc);
331
+ if (!fs.existsSync(rcPath)) continue;
332
+ const content = fs.readFileSync(rcPath, 'utf8');
333
+ if (!content.includes(userPrefix)) {
334
+ fs.appendFileSync(rcPath, `\n# Added by sentinel init\n${exportLine}\n`);
335
+ ok(`PATH updated in ~/${rc}`);
336
+ }
337
+ }
338
+ warn(`Run: export PATH="${userPrefix}/bin:$PATH" (or open a new shell)`);
339
+ warn(`Then reinstall: npm install -g @misterhuydo/sentinel`);
340
+ } catch (e) {
341
+ warn(`Could not check npm prefix: ${e.message}`);
342
+ }
343
+ }
344
+ function setupSystemd(workspace) {
345
+ const user = os.userInfo().username;
346
+ const svc = `/etc/systemd/system/sentinel.service`;
347
+ const content = `[Unit]
348
+ Description=Sentinel — Autonomous DevOps Agent
349
+ After=network-online.target
350
+ Wants=network-online.target
351
+ [Service]
352
+ Type=forking
353
+ User=${user}
354
+ WorkingDirectory=${workspace}
355
+ ExecStart=${workspace}/startAll.sh
356
+ ExecStop=${workspace}/stopAll.sh
357
+ Restart=on-failure
358
+ RestartSec=10
359
+ [Install]
360
+ WantedBy=multi-user.target
361
+ `;
362
+ try {
363
+ fs.writeFileSync('/tmp/sentinel.service', content);
364
+ execSync(`sudo mv /tmp/sentinel.service ${svc}`);
365
+ execSync('sudo systemctl daemon-reload');
366
+ execSync('sudo systemctl enable sentinel');
367
+ ok('sentinel.service enabled');
368
+ } catch (e) {
369
+ warn(`Could not write systemd service (need sudo): ${e.message}`);
370
+ warn(`Manually create ${svc} to auto-start on reboot`);
371
+ }
372
+ }
package/lib/init.js CHANGED
@@ -94,20 +94,30 @@ module.exports = async function init() {
94
94
  message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
95
95
  initial: !!(existing.SLACK_BOT_TOKEN),
96
96
  },
97
+ ], { onCancel: () => process.exit(0) });
98
+
99
+ // ── Slack app creation helper (shown before token prompts) ───────────────────
100
+ if (answers.setupSlack && !existing.SLACK_BOT_TOKEN) {
101
+ printSlackSetupGuide();
102
+ }
103
+
104
+ const tokenAnswers = await prompts([
97
105
  {
98
- type: prev => prev ? 'password' : null,
106
+ type: answers.setupSlack ? 'password' : null,
99
107
  name: 'slackBotToken',
100
108
  message: existing.SLACK_BOT_TOKEN ? 'Slack Bot Token (press Enter to keep current)' : 'Slack Bot Token (xoxb-...)',
101
109
  validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
102
110
  },
103
111
  {
104
- type: (_, { setupSlack }) => setupSlack ? 'password' : null,
112
+ type: answers.setupSlack ? 'password' : null,
105
113
  name: 'slackAppToken',
106
114
  message: existing.SLACK_APP_TOKEN ? 'Slack App-Level Token (press Enter to keep current)' : 'Slack App-Level Token (xapp-...)',
107
115
  validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
108
116
  },
109
117
  ], { onCancel: () => process.exit(0) });
110
118
 
119
+ Object.assign(answers, tokenAnswers);
120
+
111
121
  const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
112
122
  const effectiveAnthropicKey = anthropicKey || existing.ANTHROPIC_API_KEY || '';
113
123
  const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
@@ -190,7 +200,8 @@ module.exports = async function init() {
190
200
  ok('Tokens will be written to workspace sentinel.properties');
191
201
  info('Sentinel Boss starts automatically when the project starts');
192
202
  } else if (setupSlack) {
193
- warn('Slack tokens not provided — add them to config/sentinel.properties later');
203
+ warn('Slack tokens not provided — add them to sentinel.properties later');
204
+ info('Re-run sentinel init after creating the app to fill in the tokens');
194
205
  }
195
206
 
196
207
  // ── Workspace structure ─────────────────────────────────────────────────────
@@ -390,6 +401,97 @@ function ensureNpmUserPrefix() {
390
401
  }
391
402
  }
392
403
 
404
+ function buildSlackManifest() {
405
+ return {
406
+ display_information: {
407
+ name: 'Sentinel',
408
+ description: 'Autonomous DevOps Agent — monitors logs, fixes bugs, manages deployments',
409
+ background_color: '#1a1a2e',
410
+ },
411
+ features: {
412
+ bot_user: {
413
+ display_name: 'Sentinel',
414
+ always_online: true,
415
+ },
416
+ },
417
+ oauth_config: {
418
+ scopes: {
419
+ bot: [
420
+ 'app_mentions:read', // receive @Sentinel mentions
421
+ 'channels:history', // read public channel messages
422
+ 'channels:read', // list channels
423
+ 'chat:write', // post messages
424
+ 'chat:write.customize', // post with custom name/icon
425
+ 'chat:write.public', // post in channels without joining
426
+ 'files:read', // read files shared with the bot
427
+ 'files:write', // upload files (DB exports, reports)
428
+ 'groups:history', // read private channel messages
429
+ 'groups:read', // list private channels
430
+ 'im:history', // read DM messages
431
+ 'im:read', // list DMs
432
+ 'im:write', // open DM channels (for user notifications)
433
+ 'mpim:history', // read multi-person DMs
434
+ 'mpim:read', // list multi-person DMs
435
+ 'reactions:write', // add emoji reactions
436
+ 'users:read', // look up user info / timezone
437
+ 'users:read.email', // look up user by email
438
+ 'usergroups:read', // resolve @here / @channel for admin checks
439
+ ],
440
+ },
441
+ },
442
+ settings: {
443
+ event_subscriptions: {
444
+ bot_events: [
445
+ 'app_mention', // @Sentinel in any channel
446
+ 'message.channels', // messages in public channels (thread replies)
447
+ 'message.groups', // messages in private channels (thread replies)
448
+ 'message.im', // direct messages to Sentinel
449
+ 'message.mpim', // multi-person DMs
450
+ ],
451
+ },
452
+ interactivity: { is_enabled: false },
453
+ org_deploy_enabled: false,
454
+ socket_mode_enabled: true, // no public URL needed
455
+ token_rotation_enabled: false,
456
+ },
457
+ };
458
+ }
459
+
460
+ function printSlackSetupGuide() {
461
+ const manifest = buildSlackManifest();
462
+ const manifestJson = JSON.stringify(manifest);
463
+ const encoded = encodeURIComponent(manifestJson);
464
+ const createUrl = `https://api.slack.com/apps?new_app=1&manifest_json=${encoded}`;
465
+
466
+ step('Setting up Slack Bot (Sentinel Boss)…');
467
+ console.log(`
468
+ ${chalk.bold('One-click Slack app setup:')}
469
+
470
+ ${chalk.cyan(createUrl)}
471
+
472
+ ${chalk.bold('Steps after clicking the link:')}
473
+ ${chalk.white('1.')} Slack opens with all permissions pre-filled → click ${chalk.green('"Create App"')}
474
+ ${chalk.white('2.')} Click ${chalk.green('"Install to Workspace"')} → Allow
475
+ ${chalk.white('3.')} Copy ${chalk.yellow('Bot Token')} (OAuth & Permissions → Bot User OAuth Token → xoxb-...)
476
+ ${chalk.white('4.')} Go to ${chalk.cyan('Settings → Basic Information → App-Level Tokens')}
477
+ → ${chalk.green('"Generate Token and Scopes"')} name it anything, add scope: ${chalk.yellow('connections:write')}
478
+ → Copy ${chalk.yellow('App-Level Token')} (xapp-...)
479
+ ${chalk.white('5.')} Paste both tokens below ↓
480
+ `);
481
+
482
+ // Try to open in browser (Linux/Mac/WSL)
483
+ try {
484
+ const { execSync: _exec } = require('child_process');
485
+ const opener = process.platform === 'darwin' ? 'open'
486
+ : process.platform === 'win32' ? 'start'
487
+ : 'xdg-open';
488
+ _exec(`${opener} "${createUrl}"`, { stdio: 'ignore' });
489
+ console.log(chalk.green(' ✔') + ' Opened in your browser\n');
490
+ } catch (_) {
491
+ // Silently ignore if browser open fails
492
+ }
493
+ }
494
+
393
495
  function setupSystemd(workspace) {
394
496
  const user = os.userInfo().username;
395
497
  const svc = `/etc/systemd/system/sentinel.service`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.50",
3
+ "version": "1.4.52",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -395,6 +395,30 @@ _TOOLS = [
395
395
  "required": ["fingerprint"],
396
396
  },
397
397
  },
398
+ {
399
+ "name": "retry_issue",
400
+ "description": (
401
+ "Re-queue a previously failed or blocked issue from the archive without requiring the "
402
+ "user to re-type the context. Scans issues/.done/ for the most recent matching file "
403
+ "and re-submits it to Sentinel. "
404
+ "Use when the user says things like: 'retry the last issue', 're-raise the umlaut fix', "
405
+ "'try that again', 'retry Whydah-TypeLib', 'run the last failed fix again'."
406
+ ),
407
+ "input_schema": {
408
+ "type": "object",
409
+ "properties": {
410
+ "project": {
411
+ "type": "string",
412
+ "description": "Project short name (e.g. '1881'). Required.",
413
+ },
414
+ "keyword": {
415
+ "type": "string",
416
+ "description": "Optional keyword to match against archived issue content (e.g. 'umlaut', 'Whydah-TypeLib')",
417
+ },
418
+ },
419
+ "required": ["project"],
420
+ },
421
+ },
398
422
  {
399
423
  "name": "list_pending_prs",
400
424
  "description": "List all open Sentinel PRs awaiting admin review.",
@@ -1247,6 +1271,65 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1247
1271
  "note": f"Delivered to '{project_label}'. Sentinel will process it on the next poll cycle.",
1248
1272
  })
1249
1273
 
1274
+ if name == "retry_issue":
1275
+ project_arg = inputs.get("project", "").strip()
1276
+ keyword = inputs.get("keyword", "").strip().lower()
1277
+
1278
+ project_dirs = _find_project_dirs(project_arg) if project_arg else _find_project_dirs()
1279
+ if not project_dirs:
1280
+ return json.dumps({"error": f"No project found matching '{project_arg}'"})
1281
+ if len(project_dirs) > 1 and project_arg:
1282
+ return json.dumps({"error": f"Ambiguous project '{project_arg}' — matches: {[_read_project_name(d) for d in project_dirs]}"})
1283
+
1284
+ project_dir = project_dirs[0]
1285
+ done_dir = project_dir / "issues" / ".done"
1286
+ if not done_dir.exists():
1287
+ return json.dumps({"error": "No archived issues found — issues/.done/ does not exist"})
1288
+
1289
+ # Find all archived issue files, newest first
1290
+ candidates = sorted(
1291
+ [f for f in done_dir.iterdir() if f.is_file() and not f.name.startswith(".")],
1292
+ key=lambda f: f.stat().st_mtime,
1293
+ reverse=True,
1294
+ )
1295
+ if not candidates:
1296
+ return json.dumps({"error": "No archived issues found in issues/.done/"})
1297
+
1298
+ # Filter by keyword if provided
1299
+ if keyword:
1300
+ matched = []
1301
+ for f in candidates:
1302
+ try:
1303
+ content = f.read_text(encoding="utf-8", errors="replace")
1304
+ if keyword in content.lower():
1305
+ matched.append(f)
1306
+ except OSError:
1307
+ pass
1308
+ if not matched:
1309
+ return json.dumps({"error": f"No archived issues match keyword '{keyword}'"})
1310
+ candidates = matched
1311
+
1312
+ source_file = candidates[0]
1313
+ content = source_file.read_text(encoding="utf-8", errors="replace")
1314
+
1315
+ # Re-submit as a fresh issue file (new name = new fingerprint = no cooldown block)
1316
+ issues_dir = project_dir / "issues"
1317
+ issues_dir.mkdir(exist_ok=True)
1318
+ fname = f"retry-{source_file.stem[-8:]}-{uuid.uuid4().hex[:6]}.txt"
1319
+ (issues_dir / fname).write_text(content, encoding="utf-8")
1320
+ (project_dir / "SENTINEL_POLL_NOW").touch()
1321
+
1322
+ project_label = _read_project_name(project_dir.resolve())
1323
+ logger.info("Boss retry_issue: re-queued '%s' as '%s' for %s", source_file.name, fname, project_label)
1324
+ return json.dumps({
1325
+ "status": "re-queued",
1326
+ "project": project_label,
1327
+ "original_file": source_file.name,
1328
+ "new_file": fname,
1329
+ "note": f"Re-submitted '{source_file.name}' to '{project_label}'. Poll triggered.",
1330
+ })
1331
+
1332
+
1250
1333
  if name == "get_fix_details":
1251
1334
  fp = inputs["fingerprint"]
1252
1335
  fix = store.get_confirmed_fix(fp) or store.get_marker_seen_fix(fp)
@@ -8,7 +8,7 @@ Setup (api.slack.com):
8
8
  1. Create a Slack App → Enable Socket Mode → copy App-Level Token (xapp-...)
9
9
  2. Add Bot Token Scopes: app_mentions:read, chat:write, im:history,
10
10
  channels:history, users:read, files:read
11
- 3. Enable Events: app_mention, message.im, message.channels
11
+ 3. Enable Events: app_mention, message.im, message.channels, message.groups
12
12
  4. Install to workspace → copy Bot Token (xoxb-...)
13
13
  5. Add to sentinel.properties:
14
14
  SLACK_BOT_TOKEN=xoxb-...