@misterhuydo/sentinel 1.3.6 → 1.3.7

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-23T18:20:09.151Z
1
+ 2026-03-24T06:20:19.272Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-23T18:43:04.420Z",
3
- "checkpoint_at": "2026-03-23T18:43:04.421Z",
2
+ "message": "Auto-checkpoint at 2026-03-24T06:16:50.325Z",
3
+ "checkpoint_at": "2026-03-24T06:16:50.326Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
@@ -34,7 +34,7 @@ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
34
34
  exit 0
35
35
  fi
36
36
  # Kill any orphaned sentinel processes for this project (stale PIDs not in PID file)
37
- pkill -f "sentinel.main --config ${DIR}/config" 2>/dev/null || true
37
+ pkill -f "sentinel.main --config $DIR/config" 2>/dev/null || true
38
38
  rm -f "$PID_FILE"
39
39
  # Check Claude Code authentication
40
40
  AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
@@ -75,7 +75,7 @@ fi
75
75
  rm -f "$PID_FILE"
76
76
  `, { mode: 0o755 });
77
77
  }
78
- function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}) {
78
+ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}, authConfig = {}, githubToken = '') {
79
79
  const workspaceProps = path.join(workspace, 'sentinel.properties');
80
80
  if (!fs.existsSync(workspaceProps)) {
81
81
  const tplDir = path.join(__dirname, '..', 'templates');
@@ -86,122 +86,54 @@ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {})
86
86
  if (smtpConfig.password) tpl = tpl.replace('SMTP_PASSWORD=<app-password>', 'SMTP_PASSWORD=' + smtpConfig.password);
87
87
  fs.writeFileSync(workspaceProps, tpl);
88
88
  }
89
+ if (authConfig.apiKey || authConfig.claudeProForTasks !== undefined) {
90
+ let props = fs.readFileSync(workspaceProps, 'utf8');
91
+ if (authConfig.apiKey) {
92
+ if (/^#?\s*ANTHROPIC_API_KEY=/m.test(props))
93
+ props = props.replace(/^#?\s*ANTHROPIC_API_KEY=.*/mg, 'ANTHROPIC_API_KEY=' + authConfig.apiKey);
94
+ else
95
+ props = props.trimEnd() + '\nANTHROPIC_API_KEY=' + authConfig.apiKey + '\n';
96
+ }
97
+ if (authConfig.claudeProForTasks !== undefined) {
98
+ const val = authConfig.claudeProForTasks ? 'true' : 'false';
99
+ if (/^#?\s*CLAUDE_PRO_FOR_TASKS=/m.test(props))
100
+ props = props.replace(/^#?\s*CLAUDE_PRO_FOR_TASKS=.*/mg, 'CLAUDE_PRO_FOR_TASKS=' + val);
101
+ else
102
+ props = props.trimEnd() + '\nCLAUDE_PRO_FOR_TASKS=' + val + '\n';
103
+ }
104
+ fs.writeFileSync(workspaceProps, props);
105
+ }
106
+ if (githubToken) {
107
+ let props = fs.readFileSync(workspaceProps, 'utf8');
108
+ if (/^#?\s*GITHUB_TOKEN=/m.test(props))
109
+ props = props.replace(/^#?\s*GITHUB_TOKEN=.*/mg, 'GITHUB_TOKEN=' + githubToken);
110
+ else
111
+ props = props.trimEnd() + '\nGITHUB_TOKEN=' + githubToken + '\n';
112
+ fs.writeFileSync(workspaceProps, props);
113
+ }
89
114
  if (slackConfig.botToken || slackConfig.appToken) {
90
115
  let props = fs.readFileSync(workspaceProps, 'utf8');
91
116
  if (slackConfig.botToken) {
92
- const replaced = props.replace(/^#?\s*SLACK_BOT_TOKEN=.*/m, 'SLACK_BOT_TOKEN=' + slackConfig.botToken);
93
- props = replaced !== props ? replaced : props.trimEnd() + '\nSLACK_BOT_TOKEN=' + slackConfig.botToken + '\n';
117
+ if (/^#?\s*SLACK_BOT_TOKEN=/m.test(props))
118
+ props = props.replace(/^#?\s*SLACK_BOT_TOKEN=.*/mg, 'SLACK_BOT_TOKEN=' + slackConfig.botToken);
119
+ else
120
+ props = props.trimEnd() + '\nSLACK_BOT_TOKEN=' + slackConfig.botToken + '\n';
94
121
  }
95
122
  if (slackConfig.appToken) {
96
- const replaced = props.replace(/^#?\s*SLACK_APP_TOKEN=.*/m, 'SLACK_APP_TOKEN=' + slackConfig.appToken);
97
- props = replaced !== props ? replaced : props.trimEnd() + '\nSLACK_APP_TOKEN=' + slackConfig.appToken + '\n';
123
+ if (/^#?\s*SLACK_APP_TOKEN=/m.test(props))
124
+ props = props.replace(/^#?\s*SLACK_APP_TOKEN=.*/mg, 'SLACK_APP_TOKEN=' + slackConfig.appToken);
125
+ else
126
+ props = props.trimEnd() + '\nSLACK_APP_TOKEN=' + slackConfig.appToken + '\n';
98
127
  }
99
128
  fs.writeFileSync(workspaceProps, props);
100
129
  }
101
130
  fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
102
131
  # Start all valid Sentinel project instances.
103
- # A valid project must have config/repo-configs; do
104
- name=$(basename "$project_dir")
105
- [[ "$name" == "code" ]] && continue
106
- # Auto-generate start.sh / stop.sh if missing (codeDir = $WORKSPACE/code)
107
- if [[ ! -f "$project_dir/start.sh" ]]; then
108
- code_dir="$WORKSPACE/code"
109
- python_bin="$code_dir/.venv/bin/python3"
110
- sed -e "s|__NAME__|$name|g" -e "s|__CODE_DIR__|$code_dir|g" -e "s|__PYTHON_BIN__|$python_bin|g" << 'STARTSH' > "$project_dir/start.sh"
111
- #!/usr/bin/env bash
112
- set -euo pipefail
113
- DIR="$(cd "$(dirname "$0")" && pwd)"
114
- PID_FILE="$DIR/sentinel.pid"
115
- if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
116
- echo "[sentinel] __NAME__ already running (PID $(cat "$PID_FILE"))"
117
- exit 0
118
- fi
119
- pkill -f "sentinel.main --config ${DIR}/config" 2>/dev/null || true
120
- rm -f "$PID_FILE"
121
- AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
122
- if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
123
- echo "[sentinel] Claude Code is not authenticated. Run: claude then /login"
124
- exit 1
125
- fi
126
- mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
127
- cd "$DIR"
128
- PYTHONPATH="__CODE_DIR__" "__PYTHON_BIN__" -m sentinel.main --config ./config \
129
- >> "$DIR/logs/sentinel.log" 2>&1 &
130
- echo $! > "$PID_FILE"
131
- echo "[sentinel] __NAME__ started (PID $!)"
132
- STARTSH
133
- chmod +x "$project_dir/start.sh"
134
- echo "[sentinel] Auto-generated start.sh for $name"
135
- fi
136
- if [[ ! -f "$project_dir/stop.sh" ]]; then
137
- sed -e "s|__NAME__|$name|g" << 'STOPSH' > "$project_dir/stop.sh"
138
- #!/usr/bin/env bash
139
- set -euo pipefail
140
- DIR="$(cd "$(dirname "$0")" && pwd)"
141
- PID_FILE="$DIR/sentinel.pid"
142
- if [[ ! -f "$PID_FILE" ]]; then
143
- echo "[sentinel] __NAME__ — no PID file, not running"
144
- exit 0
145
- fi
146
- PID=$(cat "$PID_FILE")
147
- if kill -0 "$PID" 2>/dev/null; then
148
- kill "$PID"
149
- echo "[sentinel] __NAME__ stopped (PID $PID)"
150
- else
151
- echo "[sentinel] __NAME__ — PID $PID not running"
152
- fi
153
- rm -f "$PID_FILE"
154
- STOPSH
155
- chmod +x "$project_dir/stop.sh"
156
- echo "[sentinel] Auto-generated stop.sh for $name"
157
- fi
158
- # Must have at least one repo-config with a valid GitHub REPO_URL
159
- repo_configs_dir="$project_dir/config/repo-configs"
160
- if [[ ! -d "$repo_configs_dir" ]]; then
161
- echo "[sentinel] Skipping $name — config/repo-configs/ directory not found"
162
- skipped=$((skipped + 1))
163
- continue
164
- fi
165
- has_config=false
166
- valid_repo=false
167
- for props in "$repo_configs_dir/"*.properties; do
168
- [[ -f "$props" ]] || continue
169
- [[ "$(basename "$props")" == _* ]] && continue
170
- has_config=true
171
- if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
172
- valid_repo=true
173
- break
174
- else
175
- repo_url=$(grep -E "^REPO_URL[[:space:]]*=" "$props" | head -1 | cut -d= -f2- | xargs 2>/dev/null || true)
176
- if [[ -z "$repo_url" ]]; then
177
- echo "[sentinel] Skipping $name — REPO_URL not set in $(basename \"$props\")"
178
- else
179
- echo "[sentinel] Skipping $name — REPO_URL in $(basename \"$props\") is not a GitHub URL: $repo_url"
180
- fi
181
- fi
182
- done
183
- if [[ "$has_config" == "false" ]]; then
184
- echo "[sentinel] Skipping $name — no .properties files in config/repo-configs/ (only _example?)"
185
- skipped=$((skipped + 1))
186
- continue
187
- fi
188
- if [[ "$valid_repo" == "false" ]]; then
189
- skipped=$((skipped + 1))
190
- continue
191
- fi
192
- bash "$project_dir/start.sh"
193
- started=$((started + 1))
194
- done
195
- echo "[sentinel] $started project(s) started, $skipped skipped"
196
- `, { mode: 0o755 });
197
- // stopAll.sh
198
- fs.writeFileSync(path.join(workspace, 'stopAll.sh'), `#!/usr/bin/env bash
199
- # Stop all Sentinel project instances
200
- WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
201
- stopped=0
202
- for project_dir in "$WORKSPACE"/*/; do
132
+ # A valid project must have config/repo-configs "$WORKSPACE"/repos "$WORKSPACE"/repos/*/; do
133
+ [[ -d "$project_dir" ]] || continue
203
134
  name=$(basename "$project_dir")
204
- [[ "$name" == "code" ]] && continue
135
+ [[ "$name" == "code" ]] && continue
136
+ [[ "$name" == "repos" ]] && continue
205
137
  [[ -f "$project_dir/stop.sh" ]] || continue
206
138
  bash "$project_dir/stop.sh"
207
139
  stopped=$((stopped + 1))
@@ -0,0 +1,339 @@
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 Python…');
111
+ const python = findPython();
112
+ if (!python) {
113
+ console.error(chalk.red(' ✖ python3 not found. Install it first:'));
114
+ console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
115
+ process.exit(1);
116
+ }
117
+ ok(`Python: ${run(python, ['--version']).trim()}`);
118
+ step('Installing Sentinel code…');
119
+ const bundledPython = path.join(__dirname, '..', 'python');
120
+ if (!fs.existsSync(bundledPython)) {
121
+ console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
122
+ process.exit(1);
123
+ }
124
+ fs.ensureDirSync(codeDir);
125
+ fs.copySync(bundledPython, codeDir, { overwrite: true });
126
+ ok(`Sentinel code → ${codeDir}`);
127
+ step('Setting up Python environment…');
128
+ const venv = path.join(codeDir, '.venv');
129
+ if (!fs.existsSync(venv)) {
130
+ info('Creating virtual environment…');
131
+ runLive(python, ['-m', 'venv', venv]);
132
+ }
133
+ const pip = path.join(venv, 'bin', 'pip3');
134
+ const pythonBin = path.join(venv, 'bin', 'python3');
135
+ info('Installing Python packages…');
136
+ runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
137
+ runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
138
+ ok('Python packages installed');
139
+ step('Installing Node tools…');
140
+ installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
141
+ installNpmGlobal('@anthropic-ai/claude-code', 'claude');
142
+ info('Hooking Cairn MCP into Claude Code…');
143
+ runLive('cairn', ['install']);
144
+ step('Patching Claude Code permissions…');
145
+ ensureClaudePermissions();
146
+ step('Claude Code authentication…');
147
+ if (authMode === 'both' && effectiveAnthropicKey) {
148
+ ok('API key → Sentinel Boss (full tools, structured responses)');
149
+ ok('Claude Pro → Fix Engine + Ask Codebase (heavy coding, Pro subscription)');
150
+ info('Run `claude login` on this server now (or before starting projects)');
151
+ info('CLAUDE_PRO_FOR_TASKS=true written to workspace sentinel.properties');
152
+ } else if (authMode === 'apikey' && effectiveAnthropicKey) {
153
+ ok('API key → all Claude usage (Boss + Fix Engine billed to your API quota)');
154
+ info('CLAUDE_PRO_FOR_TASKS=false written — Fix Engine will use API key');
155
+ warn('Heavy fix tasks will consume API tokens. Claude Pro is cheaper for those.');
156
+ } else if (authMode === 'oauth') {
157
+ ok('Claude Pro / OAuth → Fix Engine + Ask Codebase');
158
+ warn('Boss will use CLI fallback — some tools unavailable without an API key');
159
+ info('Run `claude login` on this server to authenticate');
160
+ } else {
161
+ warn('No auth configured — add ANTHROPIC_API_KEY or run `claude login` before starting');
162
+ info('See: workspace sentinel.properties for full auth documentation');
163
+ }
164
+ if (effectiveSlackBotToken && effectiveSlackAppToken) {
165
+ step('Slack Bot (Sentinel Boss)…');
166
+ ok('Tokens will be written to workspace sentinel.properties');
167
+ info('Sentinel Boss starts automatically when the project starts');
168
+ } else if (setupSlack) {
169
+ warn('Slack tokens not provided — add them to config/sentinel.properties later');
170
+ }
171
+ step('Creating workspace…');
172
+ fs.ensureDirSync(workspace);
173
+ ok(`Workspace: ${workspace}`);
174
+ if (example) {
175
+ step('Creating example project…');
176
+ const exampleDir = path.join(workspace, 'my-project');
177
+ writeExampleProject(exampleDir, codeDir, pythonBin, effectiveAnthropicKey, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
178
+ ok(`Example project: ${exampleDir}`);
179
+ }
180
+ step('Generating scripts…');
181
+ const authConfig = {};
182
+ if (effectiveAnthropicKey) authConfig.apiKey = effectiveAnthropicKey;
183
+ if (authMode === 'both' || authMode === 'oauth') authConfig.claudeProForTasks = true;
184
+ if (authMode === 'apikey') authConfig.claudeProForTasks = false;
185
+ generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken }, authConfig);
186
+ ok(`${workspace}/startAll.sh`);
187
+ ok(`${workspace}/stopAll.sh`);
188
+ if (systemd) {
189
+ step('Setting up systemd…');
190
+ setupSystemd(workspace);
191
+ }
192
+ console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
193
+ console.log(`${chalk.bold('Next steps:')}`);
194
+ if (example) {
195
+ console.log(`
196
+ 1. Configure your first project:
197
+ ${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
198
+ ${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
199
+ ${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
200
+ 2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
201
+ ${chalk.cyan(`${workspace}/startAll.sh`)}
202
+ 3. Stop all projects:
203
+ ${chalk.cyan(`${workspace}/stopAll.sh`)}
204
+ `);
205
+ }
206
+ if (systemd) {
207
+ console.log(` Auto-start is enabled. To manage:
208
+ ${chalk.cyan('sudo systemctl start sentinel')}
209
+ ${chalk.cyan('sudo systemctl status sentinel')}
210
+ ${chalk.cyan('journalctl -u sentinel -f')}
211
+ `);
212
+ }
213
+ console.log(` Add another project anytime:
214
+ ${chalk.cyan('sentinel add <project-name>')}
215
+ `);
216
+ };
217
+ function readExistingConfig(workspace) {
218
+ const result = {};
219
+ _parsePropsInto(path.join(workspace, 'sentinel.properties'), result);
220
+ if (!result.SLACK_BOT_TOKEN || !result.SLACK_APP_TOKEN) {
221
+ try {
222
+ for (const entry of fs.readdirSync(workspace)) {
223
+ const p = path.join(workspace, entry, 'config', 'sentinel.properties');
224
+ const proj = {};
225
+ _parsePropsInto(p, proj);
226
+ if (!result.SLACK_BOT_TOKEN && proj.SLACK_BOT_TOKEN) result.SLACK_BOT_TOKEN = proj.SLACK_BOT_TOKEN;
227
+ if (!result.SLACK_APP_TOKEN && proj.SLACK_APP_TOKEN) result.SLACK_APP_TOKEN = proj.SLACK_APP_TOKEN;
228
+ if (result.SLACK_BOT_TOKEN && result.SLACK_APP_TOKEN) break;
229
+ }
230
+ } catch (_) {}
231
+ }
232
+ return result;
233
+ }
234
+ function _parsePropsInto(propsPath, result) {
235
+ if (!fs.existsSync(propsPath)) return;
236
+ try {
237
+ const lines = fs.readFileSync(propsPath, 'utf8').split(/\r?\n/);
238
+ for (const raw of lines) {
239
+ const line = raw.trim();
240
+ if (!line || line.startsWith('#')) continue;
241
+ const idx = line.indexOf('=');
242
+ if (idx === -1) continue;
243
+ result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
244
+ }
245
+ } catch (_) {}
246
+ }
247
+ function findPython() {
248
+ for (const bin of ['python3', 'python']) {
249
+ try {
250
+ execSync(`${bin} --version`, { stdio: 'pipe' });
251
+ return bin;
252
+ } catch (_) {}
253
+ }
254
+ return null;
255
+ }
256
+ function run(bin, args) {
257
+ const r = spawnSync(bin, args, { encoding: 'utf8' });
258
+ return (r.stdout || '') + (r.stderr || '');
259
+ }
260
+ function runLive(bin, args) {
261
+ const r = spawnSync(bin, args, { stdio: 'inherit' });
262
+ if (r.status !== 0) {
263
+ console.error(chalk.red(` ✖ Command failed: ${bin} ${args.join(' ')}`));
264
+ process.exit(1);
265
+ }
266
+ }
267
+ function installNpmGlobal(pkg, checkBin) {
268
+ try {
269
+ execSync(`${checkBin} --version`, { stdio: 'pipe' });
270
+ ok(`${pkg} already installed`);
271
+ } catch (_) {
272
+ info(`Installing ${pkg}…`);
273
+ runLive('npm', ['install', '-g', pkg]);
274
+ ok(`${pkg} installed`);
275
+ }
276
+ }
277
+ function ensureClaudePermissions() {
278
+ const settingsPath = require('path').join(require('os').homedir(), '.claude', 'settings.json');
279
+ const required = ['Read(**)', 'Write(**)', 'Edit(**)', 'Bash(**)'];
280
+ let settings = {};
281
+ try {
282
+ if (fs.existsSync(settingsPath)) {
283
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
284
+ }
285
+ } catch (e) {
286
+ warn('Could not read ' + settingsPath + ': ' + e.message);
287
+ return;
288
+ }
289
+ if (!settings.permissions) settings.permissions = {};
290
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
291
+ const existing = new Set(settings.permissions.allow);
292
+ const added = [];
293
+ for (const perm of required) {
294
+ if (!existing.has(perm)) {
295
+ settings.permissions.allow.push(perm);
296
+ added.push(perm);
297
+ }
298
+ }
299
+ if (added.length === 0) {
300
+ ok('Claude Code permissions already configured');
301
+ return;
302
+ }
303
+ try {
304
+ fs.ensureDirSync(require('path').dirname(settingsPath));
305
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
306
+ ok('Claude Code permissions patched: ' + added.join(', '));
307
+ } catch (e) {
308
+ warn('Could not write ' + settingsPath + ': ' + e.message);
309
+ }
310
+ }
311
+ function setupSystemd(workspace) {
312
+ const user = os.userInfo().username;
313
+ const svc = `/etc/systemd/system/sentinel.service`;
314
+ const content = `[Unit]
315
+ Description=Sentinel — Autonomous DevOps Agent
316
+ After=network-online.target
317
+ Wants=network-online.target
318
+ [Service]
319
+ Type=forking
320
+ User=${user}
321
+ WorkingDirectory=${workspace}
322
+ ExecStart=${workspace}/startAll.sh
323
+ ExecStop=${workspace}/stopAll.sh
324
+ Restart=on-failure
325
+ RestartSec=10
326
+ [Install]
327
+ WantedBy=multi-user.target
328
+ `;
329
+ try {
330
+ fs.writeFileSync('/tmp/sentinel.service', content);
331
+ execSync(`sudo mv /tmp/sentinel.service ${svc}`);
332
+ execSync('sudo systemctl daemon-reload');
333
+ execSync('sudo systemctl enable sentinel');
334
+ ok('sentinel.service enabled');
335
+ } catch (e) {
336
+ warn(`Could not write systemd service (need sudo): ${e.message}`);
337
+ warn(`Manually create ${svc} to auto-start on reboot`);
338
+ }
339
+ }
package/lib/add.js CHANGED
@@ -283,17 +283,17 @@ async function addFromGit(gitUrl, workspace) {
283
283
  ok(`${repoSlug}: reachable`);
284
284
 
285
285
  // ── 2. Clone primary repo and discover additional repos ────────────────────
286
- const localPath = path.join(workspace, 'repos', repoSlug);
286
+ const projectDir = path.join(workspace, name);
287
287
  step(`[2/3] Scanning repo-configs in ${repoSlug}…`);
288
288
 
289
- if (!fs.existsSync(localPath)) {
290
- spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl, localPath], {
289
+ if (!fs.existsSync(projectDir)) {
290
+ spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl, projectDir], {
291
291
  stdio: 'inherit',
292
292
  env: gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` }),
293
293
  });
294
294
  }
295
295
 
296
- const discovered = discoverReposFromClone(localPath);
296
+ const discovered = discoverReposFromClone(projectDir);
297
297
 
298
298
  // Classify each discovered repo
299
299
  const privateRepos = [];
@@ -439,8 +439,6 @@ async function addFromGit(gitUrl, workspace) {
439
439
  }
440
440
 
441
441
  // ── Preview + confirm ──────────────────────────────────────────────────────
442
- const projectDir = path.join(workspace, name);
443
-
444
442
  step('Dry-run preview');
445
443
  info(`Will create: ${projectDir}/`);
446
444
  if (discovered.length > 0) {
@@ -457,7 +455,7 @@ async function addFromGit(gitUrl, workspace) {
457
455
  }, { onCancel: () => process.exit(0) });
458
456
  if (!confirm) { info('Aborted.'); return; }
459
457
 
460
- if (fs.existsSync(projectDir) && projectDir !== localPath) {
458
+ if (fs.existsSync(projectDir) && !fs.existsSync(path.join(projectDir, '.git'))) {
461
459
  console.error(chalk.yellow(`Project "${name}" already exists at ${projectDir}`));
462
460
  process.exit(1);
463
461
  }
@@ -468,32 +466,29 @@ async function addFromGit(gitUrl, workspace) {
468
466
 
469
467
  if (discovered.length > 0) {
470
468
  // Config already exists in the cloned repo — just generate scripts
471
- generateProjectScripts(localPath, codeDir, pythonBin);
472
- // Write SSH_KEY_FILE for primary repo itself
473
- const primaryProps = path.join(localPath, 'config', 'repo-configs', `${repoSlug}.properties`);
469
+ generateProjectScripts(projectDir, codeDir, pythonBin);
470
+ // Write SSH_KEY_FILE for primary repo itself (no LOCAL_PATH — derived automatically)
471
+ const primaryProps = path.join(projectDir, 'config', 'repo-configs', `${repoSlug}.properties`);
474
472
  if (!fs.existsSync(primaryProps)) {
475
473
  writePropertiesFile(primaryProps, {
476
474
  REPO_NAME: repoSlug,
477
475
  REPO_URL: gitUrl,
478
- LOCAL_PATH: localPath,
479
476
  BRANCH: 'main',
480
477
  AUTO_PUBLISH: autoPublish ? 'true' : 'false',
481
478
  SSH_KEY_FILE: keyFile,
482
479
  CAIRN_MCP_ENABLED: 'true',
483
480
  });
484
481
  }
485
- ok(`Project "${name}" ready at ${localPath}`);
486
- printNextSteps(localPath, autoPublish);
487
- await offerToStart(localPath);
482
+ ok(`Project "${name}" ready at ${projectDir}`);
483
+ printNextSteps(projectDir, autoPublish);
484
+ await offerToStart(projectDir);
488
485
  } else {
489
486
  // No existing repo-configs — scaffold fresh project
490
- fs.ensureDirSync(projectDir);
491
487
  writeExampleProject(projectDir, codeDir, pythonBin);
492
488
  const repoDir = path.join(projectDir, 'config', 'repo-configs');
493
489
  writePropertiesFile(path.join(repoDir, `${repoSlug}.properties`), {
494
490
  REPO_NAME: repoSlug,
495
491
  REPO_URL: gitUrl,
496
- LOCAL_PATH: localPath,
497
492
  BRANCH: 'main',
498
493
  AUTO_PUBLISH: autoPublish ? 'true' : 'false',
499
494
  SSH_KEY_FILE: keyFile,
@@ -649,8 +644,6 @@ async function addFromUrl(url, workspace) {
649
644
  printNextSteps(projectDir);
650
645
  }
651
646
 
652
- // ── printNextSteps ────────────────────────────────────────────────────────────
653
-
654
647
  function printNextSteps(projectDir, autoPublish) {
655
648
  const logFile = path.join(projectDir, 'logs', 'sentinel.log');
656
649
  const mode = autoPublish === true
@@ -682,8 +675,6 @@ async function offerToStart(projectDir) {
682
675
  }
683
676
  }
684
677
 
685
- // ── entry point ───────────────────────────────────────────────────────────────
686
-
687
678
  module.exports = async function add(arg) {
688
679
  const type = detectInputType(arg);
689
680
  const workspace = await resolveWorkspace();
package/lib/generate.js CHANGED
@@ -159,7 +159,7 @@ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {},
159
159
  WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
160
160
  started=0
161
161
  skipped=0
162
- for project_dir in "$WORKSPACE"/*/ "$WORKSPACE"/repos/*/; do
162
+ for project_dir in "$WORKSPACE"/*/; do
163
163
  [[ -d "$project_dir" ]] || continue
164
164
  name=$(basename "$project_dir")
165
165
  [[ "$name" == "code" ]] && continue
@@ -266,7 +266,7 @@ echo "[sentinel] $started project(s) started, $skipped skipped"
266
266
  # Stop all Sentinel project instances
267
267
  WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
268
268
  stopped=0
269
- for project_dir in "$WORKSPACE"/*/ "$WORKSPACE"/repos/*/; do
269
+ for project_dir in "$WORKSPACE"/*/; do
270
270
  [[ -d "$project_dir" ]] || continue
271
271
  name=$(basename "$project_dir")
272
272
  [[ "$name" == "code" ]] && continue
package/lib/init.js CHANGED
@@ -153,9 +153,10 @@ module.exports = async function init() {
153
153
  // ── Node tools ──────────────────────────────────────────────────────────────
154
154
  step('Installing Node tools…');
155
155
  installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
156
- installNpmGlobal('@anthropic-ai/claude-code', 'claude');
157
- info('Hooking Cairn MCP into Claude Code…');
156
+ step('Hooking Cairn MCP into Claude Code…');
158
157
  runLive('cairn', ['install']);
158
+ ok('cairn install complete');
159
+ installNpmGlobal('@anthropic-ai/claude-code', 'claude');
159
160
  step('Patching Claude Code permissions…');
160
161
  ensureClaudePermissions();
161
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -172,7 +172,7 @@ class ConfigLoader:
172
172
  c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
173
173
  c.slack_allowed_users = _csv(d.get("SLACK_ALLOWED_USERS", ""))
174
174
  c.slack_admin_users = _csv(d.get("SLACK_ADMIN_USERS", ""))
175
- c.project_name = d.get("PROJECT_NAME", "")
175
+ c.project_name = d.get("PROJECT_NAME", "") or Path(self.config_dir).resolve().parent.name
176
176
  c.claude_pro_for_tasks = d.get("CLAUDE_PRO_FOR_TASKS", "true").lower() != "false"
177
177
  c.sync_enabled = d.get("SYNC_ENABLED", "true").lower() != "false"
178
178
  c.sync_interval_seconds = int(d.get("SYNC_INTERVAL_SECONDS", 300))
@@ -218,7 +218,7 @@ class ConfigLoader:
218
218
  r = RepoConfig()
219
219
  r.repo_name = path.stem
220
220
  r.repo_url = d.get("REPO_URL", "")
221
- r.local_path = os.path.expanduser(d.get("LOCAL_PATH", ""))
221
+ r.local_path = str(Path(self.config_dir).parent / "repos" / r.repo_name)
222
222
  r.branch = d.get("BRANCH", "main")
223
223
  r.auto_publish = d.get("AUTO_PUBLISH", "false").lower() == "true"
224
224
  r.cicd_type = d.get("CICD_TYPE", "")
@@ -135,7 +135,7 @@ def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
135
135
  return [bin_path, "--print", prompt]
136
136
 
137
137
 
138
- def _run_claude_attempt(bin_path: str, prompt: str, env: dict) -> tuple[str, bool]:
138
+ def _run_claude_attempt(bin_path: str, prompt: str, env: dict, cwd: str | None = None) -> tuple[str, bool]:
139
139
  """
140
140
  Run claude CLI with the given env. Returns (output, timed_out).
141
141
  Raises FileNotFoundError if binary is missing.
@@ -144,6 +144,7 @@ def _run_claude_attempt(bin_path: str, prompt: str, env: dict) -> tuple[str, boo
144
144
  result = subprocess.run(
145
145
  _claude_cmd(bin_path, prompt),
146
146
  capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
147
+ cwd=cwd or None,
147
148
  )
148
149
  return (result.stdout or "") + (result.stderr or ""), False
149
150
  except subprocess.TimeoutExpired:
@@ -216,7 +217,7 @@ def generate_fix(
216
217
  if env is None:
217
218
  continue
218
219
  logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
219
- output, timed_out = _run_claude_attempt(cfg.claude_code_bin, prompt, env)
220
+ output, timed_out = _run_claude_attempt(cfg.claude_code_bin, prompt, env, cwd=repo.local_path)
220
221
  if timed_out:
221
222
  logger.error("Claude Code timed out for %s", event.fingerprint)
222
223
  return "error", None, ""
@@ -19,7 +19,6 @@ from .log_parser import ErrorEvent
19
19
  logger = logging.getLogger(__name__)
20
20
 
21
21
  GIT_TIMEOUT = 60
22
- PR_BRANCH_PREFIX = "sentinel/fix-"
23
22
 
24
23
  # Files that must never be modified by Sentinel
25
24
  _PROTECTED_PATHS = {".github/", "Jenkinsfile", "pom.xml"}
@@ -144,6 +143,13 @@ def _append_changelog(repo: RepoConfig, event: ErrorEvent, commit_hash: str):
144
143
  _git(["commit", "--amend", "--no-edit"], cwd=repo.local_path, env=env)
145
144
 
146
145
 
146
+ def remote_fix_exists(repo: RepoConfig, fingerprint: str, cfg: SentinelConfig) -> bool:
147
+ """Return True if any Sentinel instance already pushed a fix branch for this fingerprint."""
148
+ pattern = f"refs/heads/*/fix-{fingerprint[:8]}"
149
+ r = _git(["ls-remote", "--heads", "origin", pattern], cwd=repo.local_path, env=_git_env(repo))
150
+ return r.returncode == 0 and bool(r.stdout.strip())
151
+
152
+
147
153
  def publish(
148
154
  event: ErrorEvent,
149
155
  repo: RepoConfig,
@@ -159,13 +165,21 @@ def publish(
159
165
  env = _git_env(repo)
160
166
  local_path = repo.local_path
161
167
 
168
+ if not repo.auto_publish:
169
+ if remote_fix_exists(repo, event.fingerprint, cfg):
170
+ logger.info(
171
+ "Remote fix branch already exists for %s — skipping duplicate push",
172
+ event.fingerprint[:8],
173
+ )
174
+ return "", ""
175
+
162
176
  if repo.auto_publish:
163
177
  r = _git(["push", "origin", repo.branch], cwd=local_path, env=env)
164
178
  if r.returncode != 0:
165
179
  logger.error("git push failed:\n%s", r.stderr)
166
180
  return repo.branch, ""
167
181
  else:
168
- branch = f"{PR_BRANCH_PREFIX}{event.fingerprint[:8]}"
182
+ branch = f"{cfg.project_name or 'sentinel'}/fix-{event.fingerprint[:8]}"
169
183
  _git(["checkout", "-B", branch], cwd=local_path, env=env)
170
184
  r = _git(["push", "-u", "origin", branch], cwd=local_path, env=env)
171
185
  if r.returncode != 0:
@@ -12,9 +12,6 @@
12
12
  # SSH clone URL of the GitHub repository
13
13
  REPO_URL=git@github.com:<org>/<repo>.git
14
14
 
15
- # Absolute path where Sentinel will clone/manage this repo on the local machine
16
- LOCAL_PATH=/home/<user>/sentinel/repos/<repo-name>
17
-
18
15
  # Branch to pull from and push fixes to
19
16
  BRANCH=main
20
17