@misterhuydo/sentinel 1.4.68 → 1.4.70

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-25T16:26:53.611Z
1
+ 2026-03-26T18:21:14.192Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-25T16:15:37.097Z",
3
- "checkpoint_at": "2026-03-25T16:15:37.098Z",
2
+ "message": "Auto-checkpoint at 2026-03-26T18:22:50.768Z",
3
+ "checkpoint_at": "2026-03-26T18:22:50.770Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
@@ -1,8 +1,8 @@
1
1
  {
2
- "J:\\Projects\\Sentinel\\cli\\lib\\add.js": {
3
- "tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\fc4a1a_add.js",
2
+ "J:\\Projects\\Sentinel\\cli\\lib\\upgrade.js": {
3
+ "tempPath": "J:\\Projects\\Sentinel\\cli\\lib\\.cairn\\views\\fb78ac_upgrade.js",
4
4
  "state": "edit-ready",
5
- "minifiedAt": 1774403831187.837,
5
+ "minifiedAt": 1774409075884.3267,
6
6
  "readCount": 1
7
7
  }
8
8
  }
@@ -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/add.js CHANGED
@@ -277,35 +277,49 @@ async function addFromGit(gitUrl, workspace) {
277
277
  validate: v => VALID_NAME.test(v) || 'Use letters, numbers, hyphens only',
278
278
  }], { onCancel: () => process.exit(0) });
279
279
 
280
- // ── 1. Generate deploy key for the primary (config) repo ───────────────────
280
+ // ── 1. Set up SSH access to the primary (config) repo ─────────────────────
281
281
  step(`[1/3] Setting up SSH access to ${repoSlug}`);
282
282
  ensureKnownHosts();
283
- const { keyFile } = generateDeployKey(repoSlug);
284
- printDeployKeyInstructions(orgRepo, keyFile);
285
283
 
286
- await prompts({
287
- type: 'text', name: '_', format: () => '',
288
- message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
289
- }, { onCancel: () => process.exit(0) });
284
+ // Check if the repo is already accessible via the existing SSH key/agent.
285
+ // This is the case when GIT_ACCESS=ssh_user_key is configured — no deploy key needed.
286
+ const existingAccess = validateAccess(gitUrl, null);
287
+ let keyFile = null;
290
288
 
291
- // Validate primary repo
292
- const primary = validateAccess(gitUrl, keyFile);
293
- if (!primary.ok) {
294
- console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
295
- if (primary.stderr) console.error(chalk.red(' ' + primary.stderr));
296
- console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
297
- process.exit(1);
289
+ if (existingAccess.ok) {
290
+ ok(`${repoSlug}: reachable via existing SSH key — skipping deploy key generation`);
291
+ } else {
292
+ const { keyFile: generatedKey } = generateDeployKey(repoSlug);
293
+ keyFile = generatedKey;
294
+ printDeployKeyInstructions(orgRepo, keyFile);
295
+
296
+ await prompts({
297
+ type: 'text', name: '_', format: () => '',
298
+ message: chalk.bold(`Press Enter once you've added the deploy key to GitHub…`),
299
+ }, { onCancel: () => process.exit(0) });
300
+
301
+ // Validate primary repo with deploy key
302
+ const primary = validateAccess(gitUrl, keyFile);
303
+ if (!primary.ok) {
304
+ console.error(chalk.red(' ✖ Cannot reach ' + gitUrl));
305
+ if (primary.stderr) console.error(chalk.red(' ' + primary.stderr));
306
+ console.error(chalk.yellow(' Check the deploy key has write access, then re-run.'));
307
+ process.exit(1);
308
+ }
309
+ ok(`${repoSlug}: reachable`);
298
310
  }
299
- ok(`${repoSlug}: reachable`);
300
311
 
301
312
  // ── 2. Clone primary repo and discover additional repos ────────────────────
302
313
  const projectDir = path.join(workspace, name);
303
314
  step(`[2/3] Scanning repo-configs in ${repoSlug}…`);
304
315
 
305
316
  if (!fs.existsSync(projectDir)) {
317
+ const cloneEnv = keyFile
318
+ ? gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` })
319
+ : gitEnv({});
306
320
  spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl, projectDir], {
307
321
  stdio: 'inherit',
308
- env: gitEnv({ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o StrictHostKeyChecking=no -o BatchMode=yes` }),
322
+ env: cloneEnv,
309
323
  });
310
324
  }
311
325
 
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.70",
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)
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """Fix the broken slack_alert block in chain_release handler."""
3
+ import ast, py_compile, tempfile, os, sys
4
+
5
+ TARGET = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
6
+
7
+ with open(TARGET, 'r', encoding='utf-8') as f:
8
+ lines = f.readlines()
9
+
10
+ # Find the broken block: look for doubled slack_alert lines near "Chain release"
11
+ # Strategy: find line with "all_ok = all(r.get" and replace everything up to
12
+ # "logger.info("Boss chain_release"
13
+
14
+ start_idx = None
15
+ end_idx = None
16
+ for i, line in enumerate(lines):
17
+ if 'all_ok = all(r.get("status") in ("released", "no_cicd")' in line:
18
+ start_idx = i
19
+ if start_idx and 'logger.info("Boss chain_release' in line:
20
+ end_idx = i
21
+ break
22
+
23
+ if start_idx is None or end_idx is None:
24
+ print(f"Could not find block: start={start_idx}, end={end_idx}")
25
+ # Print context
26
+ for i, line in enumerate(lines):
27
+ if 'chain_release' in line or 'all_ok' in line:
28
+ print(f"{i+1}: {line}", end='')
29
+ sys.exit(1)
30
+
31
+ print(f"Replacing lines {start_idx+1}..{end_idx} (inclusive of logger line)")
32
+
33
+ # Build the replacement lines (8 spaces indent = inside if name == "chain_release" block)
34
+ replacement = '''\
35
+ all_ok = all(r.get("status") in ("released", "no_cicd") for r in results)
36
+ final = "completed" if all_ok else "partial" if results else "failed"
37
+ _icon = ":white_check_mark:" if all_ok else ":warning:"
38
+ _steps = ", ".join(
39
+ "`" + r["repo"] + "` " + ("\u2713" if r.get("status") in ("released", "no_cicd") else "\u2717")
40
+ for r in results
41
+ )
42
+ slack_alert(
43
+ cfg.slack_bot_token, cfg.slack_channel,
44
+ f"{_icon}: *Chain release {final}* \u2014 {_steps}",
45
+ )
46
+ '''
47
+
48
+ # Replace lines start_idx..end_idx-1 (keep the logger line)
49
+ new_lines = lines[:start_idx] + [replacement] + lines[end_idx:]
50
+
51
+ with open(TARGET, 'w', encoding='utf-8') as f:
52
+ f.writelines(new_lines)
53
+
54
+ print("Replacement done.")
55
+
56
+ # Syntax check
57
+ with open(TARGET, 'r') as f:
58
+ src = f.read()
59
+ try:
60
+ ast.parse(src)
61
+ print("Syntax OK")
62
+ except SyntaxError as e:
63
+ print(f"SyntaxError line {e.lineno}: {e.msg}")
64
+ ls = src.splitlines()
65
+ for i in range(max(0, e.lineno-5), min(len(ls), e.lineno+3)):
66
+ print(f"{i+1}: {ls[i]}")
67
+ sys.exit(1)
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Fix Python 3.9 f-string backslash issue in chain_release handler."""
3
+
4
+ TARGET = '/home/sentinel/sentinel/code/sentinel/sentinel_boss.py'
5
+
6
+ with open(TARGET, 'r', encoding='utf-8') as f:
7
+ content = f.read()
8
+
9
+ # Replace the problematic f-string block
10
+ old = (
11
+ " f\":{'white_check_mark' if all_ok else 'warning'}: *Chain release {final}* \\u2014 \"\n"
12
+ " + \", \".join(\n"
13
+ " f\"`{r['repo']}` {'\\u2713' if r.get('status') in ('released', 'no_cicd') else '\\u2717'}\"\n"
14
+ " for r in results\n"
15
+ " ),"
16
+ )
17
+
18
+ new = (
19
+ " (\":white_check_mark:\" if all_ok else \":warning:\") + f\": *Chain release {final}* \\u2014 \"\n"
20
+ " + \", \".join(\n"
21
+ " \"`\" + r[\"repo\"] + \"` \" + (\"\\u2713\" if r.get(\"status\") in (\"released\", \"no_cicd\") else \"\\u2717\")\n"
22
+ " for r in results\n"
23
+ " ),"
24
+ )
25
+
26
+ if old not in content:
27
+ # Try finding it
28
+ idx = content.find("white_check_mark' if all_ok")
29
+ if idx >= 0:
30
+ print("Context around match point:")
31
+ print(repr(content[idx-10:idx+300]))
32
+ else:
33
+ print("Pattern not found at all")
34
+ import sys; sys.exit(1)
35
+
36
+ content = content.replace(old, new, 1)
37
+
38
+ with open(TARGET, 'w', encoding='utf-8') as f:
39
+ f.write(content)
40
+
41
+ print("Fixed f-string.")
42
+
43
+ import ast, py_compile, tempfile, os
44
+ with tempfile.NamedTemporaryFile(suffix='.py', delete=False, mode='w') as tmp:
45
+ tmp.write(content)
46
+ tmp_name = tmp.name
47
+ try:
48
+ py_compile.compile(tmp_name, doraise=True)
49
+ print("Syntax OK")
50
+ finally:
51
+ os.unlink(tmp_name)