@misterhuydo/sentinel 1.4.68 → 1.4.69

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.
@@ -4,5 +4,17 @@
4
4
  "state": "edit-ready",
5
5
  "minifiedAt": 1774403831187.837,
6
6
  "readCount": 1
7
+ },
8
+ "J:\\Projects\\Sentinel\\cli\\lib\\upgrade.js": {
9
+ "tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\fb78ac_upgrade.js",
10
+ "state": "edit-ready",
11
+ "minifiedAt": 1774409075884.3267,
12
+ "readCount": 1
13
+ },
14
+ "J:\\Projects\\Sentinel\\cli\\lib\\generate.js": {
15
+ "tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\244a09_generate.js",
16
+ "state": "compressed",
17
+ "minifiedAt": 1774454098784.183,
18
+ "readCount": 1
7
19
  }
8
20
  }
@@ -0,0 +1,274 @@
1
+ 'use strict';
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '', slackTokens = {}) {
5
+ const configDir = path.join(projectDir, 'config', 'log-configs');
6
+ const repoDir = path.join(projectDir, 'config', 'repo-configs');
7
+ fs.ensureDirSync(configDir);
8
+ fs.ensureDirSync(repoDir);
9
+ const tplDir = path.join(__dirname, '..', 'templates');
10
+ let sentinelProps = fs.readFileSync(path.join(tplDir, 'sentinel.properties'), 'utf8');
11
+ if (anthropicKey) {
12
+ sentinelProps = sentinelProps.replace(/^# ANTHROPIC_API_KEY=.*/m, `ANTHROPIC_API_KEY=${anthropicKey}`);
13
+ }
14
+ if (slackTokens.botToken) {
15
+ sentinelProps = sentinelProps.replace(/^# SLACK_BOT_TOKEN=.*/m, `SLACK_BOT_TOKEN=${slackTokens.botToken}`);
16
+ }
17
+ if (slackTokens.appToken) {
18
+ sentinelProps = sentinelProps.replace(/^# SLACK_APP_TOKEN=.*/m, `SLACK_APP_TOKEN=${slackTokens.appToken}`);
19
+ }
20
+ fs.writeFileSync(path.join(projectDir, 'config', 'sentinel.properties'), sentinelProps);
21
+ fs.copySync(path.join(tplDir, 'log-configs', '_example.properties'), path.join(configDir, '_example.properties'));
22
+ fs.copySync(path.join(tplDir, 'repo-configs', '_example.properties'), path.join(repoDir, '_example.properties'));
23
+ generateProjectScripts(projectDir, codeDir, pythonBin);
24
+ }
25
+ function generateProjectScripts(projectDir, codeDir, pythonBin) {
26
+ const name = path.basename(projectDir);
27
+ fs.writeFileSync(path.join(projectDir, 'start.sh'), `#!/usr/bin/env bash
28
+ # Start this Sentinel instance
29
+ set -euo pipefail
30
+ DIR="$(cd "$(dirname "$0")" && pwd)"
31
+ PID_FILE="$DIR/sentinel.pid"
32
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
33
+ echo "[sentinel] ${name} already running (PID $(cat "$PID_FILE"))"
34
+ exit 0
35
+ fi
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
38
+ rm -f "$PID_FILE"
39
+ WORKSPACE="$(dirname "$DIR")"
40
+ # Check Claude Code authentication — skip if CLAUDE_PRO_FOR_TASKS=false in either config
41
+ _claude_pro=true
42
+ for _conf in "$WORKSPACE/sentinel.properties" "$DIR/config/sentinel.properties"; do
43
+ if [[ -f "$_conf" ]]; then
44
+ _val=$(grep -iE "^CLAUDE_PRO_FOR_TASKS[[:space:]]*=" "$_conf" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true)
45
+ [[ -n "$_val" ]] && _claude_pro="$_val"
46
+ fi
47
+ done
48
+ if [[ "$_claude_pro" != "false" ]]; then
49
+ AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
50
+ if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
51
+ echo ""
52
+ echo "[sentinel] Claude Code is not authenticated."
53
+ echo " 1. Open a new terminal and run: claude"
54
+ echo " 2. Type /login at the prompt"
55
+ echo " 3. Open the URL in any browser and log in"
56
+ echo " 4. Type /exit when done"
57
+ echo " 5. Re-run this script"
58
+ echo ""
59
+ exit 1
60
+ fi
61
+ fi
62
+ mkdir -p "$DIR/logs" "$WORKSPACE/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
63
+ cd "$DIR"
64
+ # Ensure npm-global bin (cairn-mcp, claude) and ~/.local/bin (auto-installed tools) are on PATH
65
+ export PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$PATH"
66
+ PYTHONPATH="${codeDir}" "${codeDir}/.venv/bin/python3" -m sentinel.main --config ./config \\
67
+ >> "$DIR/logs/sentinel.log" 2>&1 &
68
+ echo $! > "$PID_FILE"
69
+ echo "[sentinel] ${name} started (PID $!)"
70
+ echo " project log : $DIR/logs/sentinel.log"
71
+ echo " workspace log: $WORKSPACE/logs/sentinel.log"
72
+ `, { mode: 0o755 });
73
+ fs.writeFileSync(path.join(projectDir, 'stop.sh'), `#!/usr/bin/env bash
74
+ # Stop this Sentinel instance
75
+ set -euo pipefail
76
+ DIR="$(cd "$(dirname "$0")" && pwd)"
77
+ PID_FILE="$DIR/sentinel.pid"
78
+ if [[ ! -f "$PID_FILE" ]]; then
79
+ echo "[sentinel] ${name} — no PID file, not running"
80
+ exit 0
81
+ fi
82
+ PID=$(cat "$PID_FILE")
83
+ if kill -0 "$PID" 2>/dev/null; then
84
+ kill "$PID"
85
+ echo "[sentinel] ${name} stopped (PID $PID)"
86
+ else
87
+ echo "[sentinel] ${name} — PID $PID not running"
88
+ fi
89
+ rm -f "$PID_FILE"
90
+ `, { mode: 0o755 });
91
+ }
92
+ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}, authConfig = {}, githubToken = '') {
93
+ const workspaceProps = path.join(workspace, 'sentinel.properties');
94
+ if (!fs.existsSync(workspaceProps)) {
95
+ const tplDir = path.join(__dirname, '..', 'templates');
96
+ let tpl = fs.readFileSync(path.join(tplDir, 'workspace-sentinel.properties'), 'utf8');
97
+ if (smtpConfig.host) tpl = tpl.replace('SMTP_HOST=smtp.gmail.com', 'SMTP_HOST=' + smtpConfig.host);
98
+ if (smtpConfig.port) tpl = tpl.replace('SMTP_PORT=587', 'SMTP_PORT=' + smtpConfig.port);
99
+ if (smtpConfig.user) tpl = tpl.replace('SMTP_USER=sentinel@yourdomain.com', 'SMTP_USER=' + smtpConfig.user);
100
+ if (smtpConfig.password) tpl = tpl.replace('SMTP_PASSWORD=<app-password>', 'SMTP_PASSWORD=' + smtpConfig.password);
101
+ fs.writeFileSync(workspaceProps, tpl);
102
+ }
103
+ if (authConfig.apiKey || authConfig.claudeProForTasks !== undefined) {
104
+ let props = fs.readFileSync(workspaceProps, 'utf8');
105
+ if (authConfig.apiKey) {
106
+ if (/^#?\s*ANTHROPIC_API_KEY=/m.test(props))
107
+ props = props.replace(/^#?\s*ANTHROPIC_API_KEY=.*/mg, 'ANTHROPIC_API_KEY=' + authConfig.apiKey);
108
+ else
109
+ props = props.trimEnd() + '\nANTHROPIC_API_KEY=' + authConfig.apiKey + '\n';
110
+ }
111
+ if (authConfig.claudeProForTasks !== undefined) {
112
+ const val = authConfig.claudeProForTasks ? 'true' : 'false';
113
+ if (/^#?\s*CLAUDE_PRO_FOR_TASKS=/m.test(props))
114
+ props = props.replace(/^#?\s*CLAUDE_PRO_FOR_TASKS=.*/mg, 'CLAUDE_PRO_FOR_TASKS=' + val);
115
+ else
116
+ props = props.trimEnd() + '\nCLAUDE_PRO_FOR_TASKS=' + val + '\n';
117
+ }
118
+ fs.writeFileSync(workspaceProps, props);
119
+ }
120
+ if (githubToken) {
121
+ let props = fs.readFileSync(workspaceProps, 'utf8');
122
+ if (/^#?\s*GITHUB_TOKEN=/m.test(props))
123
+ props = props.replace(/^#?\s*GITHUB_TOKEN=.*/mg, 'GITHUB_TOKEN=' + githubToken);
124
+ else
125
+ props = props.trimEnd() + '\nGITHUB_TOKEN=' + githubToken + '\n';
126
+ fs.writeFileSync(workspaceProps, props);
127
+ }
128
+ if (slackConfig.botToken || slackConfig.appToken) {
129
+ let props = fs.readFileSync(workspaceProps, 'utf8');
130
+ if (slackConfig.botToken) {
131
+ if (/^#?\s*SLACK_BOT_TOKEN=/m.test(props))
132
+ props = props.replace(/^#?\s*SLACK_BOT_TOKEN=.*/mg, 'SLACK_BOT_TOKEN=' + slackConfig.botToken);
133
+ else
134
+ props = props.trimEnd() + '\nSLACK_BOT_TOKEN=' + slackConfig.botToken + '\n';
135
+ }
136
+ if (slackConfig.appToken) {
137
+ if (/^#?\s*SLACK_APP_TOKEN=/m.test(props))
138
+ props = props.replace(/^#?\s*SLACK_APP_TOKEN=.*/mg, 'SLACK_APP_TOKEN=' + slackConfig.appToken);
139
+ else
140
+ props = props.trimEnd() + '\nSLACK_APP_TOKEN=' + slackConfig.appToken + '\n';
141
+ }
142
+ fs.writeFileSync(workspaceProps, props);
143
+ }
144
+ fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
145
+ # Start all valid Sentinel project instances.
146
+ # A valid project must have config/repo-configs; do
147
+ [[ -d "$project_dir" ]] || continue
148
+ name=$(basename "$project_dir")
149
+ echo " $NON_PROJECT " | grep -qw "$name" && continue
150
+ # Auto-generate start.sh / stop.sh if missing (codeDir = $WORKSPACE/code)
151
+ if [[ ! -f "$project_dir/start.sh" ]]; then
152
+ code_dir="$WORKSPACE/code"
153
+ python_bin="$code_dir/.venv/bin/python3"
154
+ 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"
155
+ #!/usr/bin/env bash
156
+ set -euo pipefail
157
+ DIR="$(cd "$(dirname "$0")" && pwd)"
158
+ PID_FILE="$DIR/sentinel.pid"
159
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
160
+ echo "[sentinel] __NAME__ already running (PID $(cat "$PID_FILE"))"
161
+ exit 0
162
+ fi
163
+ pkill -f "sentinel.main --config $DIR/config" 2>/dev/null || true
164
+ rm -f "$PID_FILE"
165
+ WORKSPACE="$(dirname "$DIR")"
166
+ _claude_pro=true
167
+ for _conf in "$WORKSPACE/sentinel.properties" "$DIR/config/sentinel.properties"; do
168
+ if [[ -f "$_conf" ]]; then
169
+ _val=$(grep -iE "^CLAUDE_PRO_FOR_TASKS[[:space:]]*=" "$_conf" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d ' ' | tr '[:upper:]' '[:lower:]' || true)
170
+ [[ -n "$_val" ]] && _claude_pro="$_val"
171
+ fi
172
+ done
173
+ if [[ "$_claude_pro" != "false" ]]; then
174
+ AUTH_OUT=$(claude --print \"hi\" 2>&1 || true)
175
+ if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
176
+ echo "[sentinel] Claude Code is not authenticated. Run: claude then /login"
177
+ exit 1
178
+ fi
179
+ fi
180
+ mkdir -p "$DIR/logs" "$WORKSPACE/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
181
+ cd "$DIR"
182
+ PYTHONPATH="__CODE_DIR__" "__PYTHON_BIN__" -m sentinel.main --config ./config \
183
+ > "$DIR/logs/sentinel.log" 2>&1 &
184
+ echo $! > "$PID_FILE"
185
+ echo "[sentinel] __NAME__ started (PID $!)"
186
+ echo " project log : $DIR/logs/sentinel.log"
187
+ echo " workspace log: $WORKSPACE/logs/sentinel.log"
188
+ STARTSH
189
+ chmod +x "$project_dir/start.sh"
190
+ echo "[sentinel] Auto-generated start.sh for $name"
191
+ fi
192
+ if [[ ! -f "$project_dir/stop.sh" ]]; then
193
+ sed -e "s|__NAME__|$name|g" << 'STOPSH' > "$project_dir/stop.sh"
194
+ #!/usr/bin/env bash
195
+ set -euo pipefail
196
+ DIR="$(cd "$(dirname "$0")" && pwd)"
197
+ PID_FILE="$DIR/sentinel.pid"
198
+ if [[ ! -f "$PID_FILE" ]]; then
199
+ echo "[sentinel] __NAME__ — no PID file, not running"
200
+ exit 0
201
+ fi
202
+ PID=$(cat "$PID_FILE")
203
+ if kill -0 "$PID" 2>/dev/null; then
204
+ kill "$PID"
205
+ echo "[sentinel] __NAME__ stopped (PID $PID)"
206
+ else
207
+ echo "[sentinel] __NAME__ — PID $PID not running"
208
+ fi
209
+ rm -f "$PID_FILE"
210
+ STOPSH
211
+ chmod +x "$project_dir/stop.sh"
212
+ echo "[sentinel] Auto-generated stop.sh for $name"
213
+ fi
214
+ # Must have at least one repo-config with a valid GitHub REPO_URL
215
+ repo_configs_dir="$project_dir/config/repo-configs"
216
+ if [[ ! -d "$repo_configs_dir" ]]; then
217
+ echo "[sentinel] Skipping $name — config/repo-configs/ directory not found"
218
+ skipped=$((skipped + 1))
219
+ continue
220
+ fi
221
+ has_config=false
222
+ valid_repo=false
223
+ for props in "$repo_configs_dir/"*.properties; do
224
+ [[ -f "$props" ]] || continue
225
+ [[ "$(basename "$props")" == _* ]] && continue
226
+ has_config=true
227
+ if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
228
+ valid_repo=true
229
+ break
230
+ else
231
+ repo_url=$(grep -E "^REPO_URL[[:space:]]*=" "$props" | head -1 | cut -d= -f2- | xargs 2>/dev/null || true)
232
+ if [[ -z "$repo_url" ]]; then
233
+ echo "[sentinel] Skipping $name — REPO_URL not set in $(basename \"$props\")"
234
+ else
235
+ echo "[sentinel] Skipping $name — REPO_URL in $(basename \"$props\") is not a GitHub URL: $repo_url"
236
+ fi
237
+ fi
238
+ done
239
+ if [[ "$has_config" == "false" ]]; then
240
+ echo "[sentinel] Skipping $name — no .properties files in config/repo-configs/ (only _example?)"
241
+ skipped=$((skipped + 1))
242
+ continue
243
+ fi
244
+ if [[ "$valid_repo" == "false" ]]; then
245
+ skipped=$((skipped + 1))
246
+ continue
247
+ fi
248
+ if bash "$project_dir/start.sh"; then
249
+ started=$((started + 1))
250
+ else
251
+ echo "[sentinel] Failed to start $name"
252
+ skipped=$((skipped + 1))
253
+ fi
254
+ done
255
+ echo "[sentinel] $started project(s) started, $skipped skipped"
256
+ `, { mode: 0o755 });
257
+ // stopAll.sh
258
+ fs.writeFileSync(path.join(workspace, 'stopAll.sh'), `#!/usr/bin/env bash
259
+ # Stop all Sentinel project instances
260
+ WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
261
+ stopped=0
262
+ NON_PROJECT="code repos logs issues workspace"
263
+ for project_dir in "$WORKSPACE"/*/; do
264
+ [[ -d "$project_dir" ]] || continue
265
+ name=$(basename "$project_dir")
266
+ echo " $NON_PROJECT " | grep -qw "$name" && continue
267
+ [[ -f "$project_dir/stop.sh" ]] || continue
268
+ bash "$project_dir/stop.sh"
269
+ stopped=$((stopped + 1))
270
+ done
271
+ echo "[sentinel] $stopped project(s) stopped"
272
+ `, { mode: 0o755 });
273
+ }
274
+ module.exports = { writeExampleProject, generateProjectScripts, generateWorkspaceScripts };
@@ -91,6 +91,21 @@ module.exports = async function upgrade() {
91
91
  if (shFiles.length) spawnSync('chmod', ['+x', ...shFiles], { stdio: 'inherit' });
92
92
  }
93
93
  ok('Python source updated');
94
+ info('Updating Python packages...');
95
+ const venv = path.join(codeDir, '.venv');
96
+ const pip = fs.existsSync(path.join(venv, 'bin', 'pip')) ? path.join(venv, 'bin', 'pip')
97
+ : fs.existsSync(path.join(venv, 'Scripts', 'pip.exe')) ? path.join(venv, 'Scripts', 'pip.exe')
98
+ : null;
99
+ if (pip) {
100
+ const pipResult = spawnSync(pip, ['install', '--upgrade', '-r', path.join(codeDir, 'requirements.txt')], { stdio: 'inherit' });
101
+ if (pipResult.status !== 0) {
102
+ warn('pip install failed — some Python packages may be missing');
103
+ } else {
104
+ ok('Python packages up to date');
105
+ }
106
+ } else {
107
+ warn('venv not found — skipping pip install (run: sentinel init)');
108
+ }
94
109
  info('Patching Claude Code permissions…');
95
110
  ensureClaudePermissions();
96
111
  const { version: latest } = require(path.join(pkgDir, 'package.json'));
@@ -98,7 +113,7 @@ module.exports = async function upgrade() {
98
113
  info('Regenerating scripts...');
99
114
  const { generateProjectScripts, generateWorkspaceScripts } = require('./generate');
100
115
  generateWorkspaceScripts(defaultWorkspace);
101
- const pythonBin = fs.existsSync('/usr/bin/python3') ? '/usr/bin/python3' : 'python3';
116
+ const pythonBin = path.join(venv, 'bin', 'python3');
102
117
  const NON_PROJECT_DIRS = new Set(['logs', 'code', 'repos', 'workspace', 'issues']);
103
118
  let regenerated = 0;
104
119
  try {
package/lib/generate.js CHANGED
@@ -75,8 +75,13 @@ if [[ "$_claude_pro" != "false" ]]; then
75
75
  fi
76
76
  mkdir -p "$DIR/logs" "$WORKSPACE/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
77
77
  cd "$DIR"
78
- # Ensure npm-global bin (cairn-mcp, claude) and ~/.local/bin (auto-installed tools) are on PATH
78
+ # Auto-detect JAVA_HOME (needed for Maven)
79
+ for _jdk in "$HOME"/jdk-* "$HOME"/.jdk /usr/lib/jvm/java-21-openjdk /usr/lib/jvm/java-11-openjdk; do
80
+ if [[ -d "$_jdk" ]] && [[ -x "$_jdk/bin/java" ]]; then export JAVA_HOME="$_jdk"; break; fi
81
+ done
82
+ # Ensure npm-global bin (cairn-mcp, claude), ~/.local/bin (auto-installed tools), and JAVA_HOME on PATH
79
83
  export PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$PATH"
84
+ [[ -n "$JAVA_HOME" ]] && export PATH="$JAVA_HOME/bin:$PATH"
80
85
  PYTHONPATH="${codeDir}" "${codeDir}/.venv/bin/python3" -m sentinel.main --config ./config \\
81
86
  >> "$DIR/logs/sentinel.log" 2>&1 &
82
87
  echo $! > "$PID_FILE"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.4.68",
3
+ "version": "1.4.69",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Improve ask_codebase:
4
+ 1. Enrich prompt with project context (all repos Sentinel manages, their paths/URLs)
5
+ 2. Add optional 'mode' param: explore (default) | issues
6
+ 3. 'issues' mode: Claude outputs structured GitHub issue suggestions
7
+ 4. Tool description updated to mention project-level discussions and issue raising
8
+ """
9
+ import ast, sys
10
+
11
+ BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
12
+
13
+ with open(BOSS, 'r', encoding='utf-8') as f:
14
+ boss = f.read()
15
+
16
+ # ── 1. Update tool definition ─────────────────────────────────────────────────
17
+
18
+ OLD_DEF = ''' "name": "ask_codebase",
19
+ "description": (
20
+ "Ask any natural-language question about a managed codebase. "
21
+ "Accepts a repo name (e.g. 'STS', 'elprint-sales') OR a project name (e.g. '1881', 'elprint') "
22
+ "— if a project name is given and it has multiple repos, all are queried. "
23
+ "Claude Code answers using its full codebase knowledge — no need to specify how. "
24
+ "Use for: 'what does 1881 do?', 'TODOs in 1881', 'find PIN validation in STS', "
25
+ "'security issues in elprint-sales?', 'summarize the cairn repo'."
26
+ ),
27
+ "input_schema": {
28
+ "type": "object",
29
+ "properties": {
30
+ "repo": {
31
+ "type": "string",
32
+ "description": "Repo name (e.g. 'STS', 'elprint-sales') OR project name (e.g. '1881', 'elprint') — project name queries all its repos",
33
+ },
34
+ "question": {
35
+ "type": "string",
36
+ "description": "Natural language question about the codebase",
37
+ },
38
+ },
39
+ "required": ["repo", "question"],
40
+ },
41
+ },'''
42
+
43
+ NEW_DEF = ''' "name": "ask_codebase",
44
+ "description": (
45
+ "Ask any natural-language question about a managed codebase — or discuss architecture, "
46
+ "extensions, and new features. Claude Code explores the repo(s) with full file access. "
47
+ "Accepts a repo name (e.g. 'STS', 'TypeLib') OR project name (e.g. '1881', 'Whydah') — "
48
+ "project name queries all its repos. "
49
+ "Use for: 'what does 1881 do?', 'describe the project structure', "
50
+ "'what should we implement next in STS?', 'find security issues in elprint-sales', "
51
+ "'what features could we add to TypeLib?', 'summarise the cairn architecture'. "
52
+ "Use mode=issues to make Claude output structured GitHub issue suggestions "
53
+ "(e.g. 'raise issues for improvements in STS', 'what bugs should we track in 1881?')."
54
+ ),
55
+ "input_schema": {
56
+ "type": "object",
57
+ "properties": {
58
+ "repo": {
59
+ "type": "string",
60
+ "description": "Repo name (e.g. 'STS', 'TypeLib') OR project name (e.g. '1881', 'Whydah')",
61
+ },
62
+ "question": {
63
+ "type": "string",
64
+ "description": "Natural language question, task, or discussion prompt about the codebase",
65
+ },
66
+ "mode": {
67
+ "type": "string",
68
+ "enum": ["explore", "issues"],
69
+ "description": (
70
+ "explore (default): answer freely, describe structure, discuss architecture. "
71
+ "issues: Claude outputs structured GitHub issue suggestions "
72
+ "(title + description + labels) that can be raised on GitHub."
73
+ ),
74
+ },
75
+ },
76
+ "required": ["repo", "question"],
77
+ },
78
+ },'''
79
+
80
+ if OLD_DEF not in boss:
81
+ print("ERROR: ask_codebase tool definition not found")
82
+ sys.exit(1)
83
+ boss = boss.replace(OLD_DEF, NEW_DEF, 1)
84
+ print("Step 1 OK: tool definition updated")
85
+
86
+ # ── 2. Update handler — richer prompt + mode support ─────────────────────────
87
+
88
+ OLD_HANDLER = ''' cfg = cfg_loader.sentinel
89
+ env = os.environ.copy()
90
+ # Only inject API key when Claude Pro is NOT preferred for heavy tasks
91
+ if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
92
+ env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
93
+
94
+ def _ask_one(repo_name, repo_cfg) -> dict:
95
+ local_path = Path(repo_cfg.local_path)
96
+ if not local_path.exists():
97
+ return {"repo": repo_name, "error": f"not cloned yet at {local_path}"}
98
+ prompt = (
99
+ f"You are a code analyst. Answer the following question about the codebase at: {local_path}\\n\\n"
100
+ f"Question: {question}\\n\\n"
101
+ f"Use whatever tools you need to answer accurately. Be concise and direct. Plain text only."
102
+ )
103
+ try:
104
+ r = subprocess.run(
105
+ ([cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt]
106
+ if os.getuid() != 0 else
107
+ [cfg.claude_code_bin, "--print", prompt]),
108
+ capture_output=True, text=True, timeout=180, env=env,
109
+ cwd=str(local_path), stdin=subprocess.DEVNULL,
110
+ )
111
+ output = (r.stdout or "").strip()
112
+ logger.info("Boss ask_codebase %s rc=%d len=%d", repo_name, r.returncode, len(output))
113
+ if r.returncode != 0 and not output:
114
+ raw_err = (r.stderr or "")
115
+ alert_if_rate_limited(
116
+ cfg.slack_bot_token, cfg.slack_channel,
117
+ f"ask_codebase/{repo_name}", raw_err,
118
+ )
119
+ return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {raw_err[:200]}"}
120
+ return {"repo": repo_name, "answer": output[:3000]}
121
+ except subprocess.TimeoutExpired:
122
+ return {"repo": repo_name, "error": "timed out after 180s"}
123
+ except Exception as e:
124
+ return {"repo": repo_name, "error": str(e)}
125
+
126
+ if len(matched) == 1:
127
+ result = _ask_one(*matched[0])
128
+ # Unwrap single-repo result for cleaner response
129
+ return json.dumps(result)
130
+
131
+ # Multiple repos — query each and combine
132
+ results = [_ask_one(rn, r) for rn, r in matched]
133
+ return json.dumps({"project": target, "repos_queried": len(results), "results": results})'''
134
+
135
+ NEW_HANDLER = ''' mode = inputs.get("mode", "explore")
136
+
137
+ cfg = cfg_loader.sentinel
138
+ env = os.environ.copy()
139
+ # Only inject API key when Claude Pro is NOT preferred for heavy tasks
140
+ if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
141
+ env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
142
+
143
+ # Build project context block (all repos this Sentinel instance manages)
144
+ _project_name = _read_project_name(Path("."))
145
+ _all_repos_lines = []
146
+ for _rn, _rc in cfg_loader.repos.items():
147
+ _url = getattr(_rc, "repo_url", "") or ""
148
+ _path = getattr(_rc, "local_path", "") or ""
149
+ _prefixes = getattr(_rc, "package_prefixes", "") or ""
150
+ _all_repos_lines.append(
151
+ f" - {_rn}: path={_path}"
152
+ + (f", url={_url}" if _url else "")
153
+ + (f", packages={_prefixes}" if _prefixes else "")
154
+ )
155
+ _project_ctx = (
156
+ f"Project: {_project_name}\\n"
157
+ f"Repos managed by this Sentinel instance:\\n"
158
+ + "\\n".join(_all_repos_lines)
159
+ )
160
+
161
+ def _ask_one(repo_name, repo_cfg) -> dict:
162
+ local_path = Path(repo_cfg.local_path)
163
+ if not local_path.exists():
164
+ return {"repo": repo_name, "error": f"not cloned yet at {local_path}"}
165
+
166
+ if mode == "issues":
167
+ _mode_instruction = (
168
+ "Output a structured list of GitHub issues to raise for this codebase.\\n"
169
+ "For each issue include:\\n"
170
+ " TITLE: <concise issue title>\\n"
171
+ " LABELS: <bug|enhancement|tech-debt|security|performance>\\n"
172
+ " DESCRIPTION: <2-4 sentences: what, why, suggested approach>\\n"
173
+ "---\\n"
174
+ "Focus on: bugs, missing error handling, performance bottlenecks, "
175
+ "security gaps, missing tests, tech-debt, and useful new features.\\n"
176
+ "Output plain text only — no markdown headers."
177
+ )
178
+ else:
179
+ _mode_instruction = (
180
+ "Explore the codebase freely — read files, search for patterns, examine structure.\\n"
181
+ "Answer thoroughly. You may discuss architecture, suggest extensions, "
182
+ "describe design patterns, or analyse any aspect of the code.\\n"
183
+ "Plain text only. Be concise but complete."
184
+ )
185
+
186
+ prompt = (
187
+ f"{_project_ctx}\\n\\n"
188
+ f"You are now analysing: {repo_name} at {local_path}\\n\\n"
189
+ f"{_mode_instruction}\\n\\n"
190
+ f"Question / Task: {question}"
191
+ )
192
+ try:
193
+ r = subprocess.run(
194
+ ([cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt]
195
+ if os.getuid() != 0 else
196
+ [cfg.claude_code_bin, "--print", prompt]),
197
+ capture_output=True, text=True, timeout=300, env=env,
198
+ cwd=str(local_path), stdin=subprocess.DEVNULL,
199
+ )
200
+ output = (r.stdout or "").strip()
201
+ logger.info("Boss ask_codebase %s mode=%s rc=%d len=%d", repo_name, mode, r.returncode, len(output))
202
+ if r.returncode != 0 and not output:
203
+ raw_err = (r.stderr or "")
204
+ alert_if_rate_limited(
205
+ cfg.slack_bot_token, cfg.slack_channel,
206
+ f"ask_codebase/{repo_name}", raw_err,
207
+ )
208
+ return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {raw_err[:200]}"}
209
+ return {"repo": repo_name, "answer": output[:4000]}
210
+ except subprocess.TimeoutExpired:
211
+ return {"repo": repo_name, "error": "timed out after 300s"}
212
+ except Exception as e:
213
+ return {"repo": repo_name, "error": str(e)}
214
+
215
+ if len(matched) == 1:
216
+ result = _ask_one(*matched[0])
217
+ # Unwrap single-repo result for cleaner response
218
+ return json.dumps(result)
219
+
220
+ # Multiple repos — query each and combine
221
+ results = [_ask_one(rn, r) for rn, r in matched]
222
+ return json.dumps({"project": target, "repos_queried": len(results), "results": results})'''
223
+
224
+ if OLD_HANDLER not in boss:
225
+ print("ERROR: ask_codebase handler not found")
226
+ # Show context to help debug
227
+ idx = boss.find("def _ask_one")
228
+ if idx >= 0:
229
+ print(repr(boss[idx-200:idx+100]))
230
+ sys.exit(1)
231
+ boss = boss.replace(OLD_HANDLER, NEW_HANDLER, 1)
232
+ print("Step 2 OK: handler updated with richer prompt + mode support")
233
+
234
+ with open(BOSS, 'w', encoding='utf-8') as f:
235
+ f.write(boss)
236
+ print("Written OK")
237
+
238
+ # ── Syntax check ──────────────────────────────────────────────────────────────
239
+ with open(BOSS, 'r', encoding='utf-8') as f:
240
+ src = f.read()
241
+ try:
242
+ ast.parse(src)
243
+ print("Syntax OK")
244
+ except SyntaxError as e:
245
+ lines = src.splitlines()
246
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
247
+ for i in range(max(0, e.lineno-5), min(len(lines), e.lineno+3)):
248
+ print(f" {i+1}: {lines[i]}")
249
+ sys.exit(1)
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """Fix ask_codebase: add stdin=DEVNULL so claude --print doesn't hang waiting for stdin."""
3
+ import ast, sys
4
+
5
+ BOSS = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
6
+
7
+ with open(BOSS, 'r', encoding='utf-8') as f:
8
+ content = f.read()
9
+
10
+ OLD = (
11
+ ' r = subprocess.run(\n'
12
+ ' ([cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt]\n'
13
+ ' if os.getuid() != 0 else\n'
14
+ ' [cfg.claude_code_bin, "--print", prompt]),\n'
15
+ ' capture_output=True, text=True, timeout=180, env=env,\n'
16
+ ' cwd=str(local_path),\n'
17
+ ' )'
18
+ )
19
+
20
+ NEW = (
21
+ ' r = subprocess.run(\n'
22
+ ' ([cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt]\n'
23
+ ' if os.getuid() != 0 else\n'
24
+ ' [cfg.claude_code_bin, "--print", prompt]),\n'
25
+ ' capture_output=True, text=True, timeout=180, env=env,\n'
26
+ ' cwd=str(local_path), stdin=subprocess.DEVNULL,\n'
27
+ ' )'
28
+ )
29
+
30
+ if OLD not in content:
31
+ print("ERROR: target block not found")
32
+ # Show context
33
+ idx = content.find('capture_output=True, text=True, timeout=180')
34
+ if idx >= 0:
35
+ print(repr(content[idx-200:idx+100]))
36
+ sys.exit(1)
37
+
38
+ content = content.replace(OLD, NEW, 1)
39
+
40
+ with open(BOSS, 'w', encoding='utf-8') as f:
41
+ f.write(content)
42
+ print("Fixed: stdin=DEVNULL added to ask_codebase subprocess call")
43
+
44
+ try:
45
+ ast.parse(content)
46
+ print("Syntax OK")
47
+ except SyntaxError as e:
48
+ print(f"SyntaxError at line {e.lineno}: {e.msg}")
49
+ sys.exit(1)