@misterhuydo/sentinel 1.3.6 → 1.3.8

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/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.8",
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", "")
@@ -11,6 +11,7 @@ import logging
11
11
  import re
12
12
  import subprocess
13
13
  import textwrap
14
+ from datetime import datetime, timezone
14
15
  from pathlib import Path
15
16
 
16
17
  from .config_loader import RepoConfig, SentinelConfig
@@ -135,21 +136,50 @@ def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
135
136
  return [bin_path, "--print", prompt]
136
137
 
137
138
 
138
- def _run_claude_attempt(bin_path: str, prompt: str, env: dict) -> tuple[str, bool]:
139
+ def _run_claude_attempt(
140
+ bin_path: str,
141
+ prompt: str,
142
+ env: dict,
143
+ cwd: str | None = None,
144
+ claude_log_path: Path | None = None,
145
+ ) -> tuple[str, bool]:
139
146
  """
140
147
  Run claude CLI with the given env. Returns (output, timed_out).
141
148
  Raises FileNotFoundError if binary is missing.
149
+ If claude_log_path is given, writes the full prompt + raw output there.
142
150
  """
143
151
  try:
144
152
  result = subprocess.run(
145
153
  _claude_cmd(bin_path, prompt),
146
154
  capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
155
+ cwd=cwd or None,
147
156
  )
148
- return (result.stdout or "") + (result.stderr or ""), False
157
+ output = (result.stdout or "") + (result.stderr or "")
158
+ if claude_log_path:
159
+ _write_claude_log(claude_log_path, prompt, output, timed_out=False)
160
+ return output, False
149
161
  except subprocess.TimeoutExpired:
162
+ if claude_log_path:
163
+ _write_claude_log(claude_log_path, prompt, "", timed_out=True)
150
164
  return "", True
151
165
 
152
166
 
167
+ def _write_claude_log(log_path: Path, prompt: str, output: str, timed_out: bool) -> None:
168
+ log_path.parent.mkdir(parents=True, exist_ok=True)
169
+ sep = "─" * 72
170
+ content = (
171
+ f"{sep}\n"
172
+ f"PROMPT\n"
173
+ f"{sep}\n"
174
+ f"{prompt}\n\n"
175
+ f"{sep}\n"
176
+ f"OUTPUT{' [TIMED OUT]' if timed_out else ''}\n"
177
+ f"{sep}\n"
178
+ f"{output if output else '(no output)'}\n"
179
+ )
180
+ log_path.write_text(content, encoding="utf-8")
181
+
182
+
153
183
  def generate_fix(
154
184
  event: ErrorEvent,
155
185
  repo: RepoConfig,
@@ -194,7 +224,13 @@ def generate_fix(
194
224
  except Exception as _e:
195
225
  logger.debug("fix_engine: git log check failed: %s", _e)
196
226
 
197
- logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
227
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
228
+ claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
229
+ claude_log_path = claude_logs_dir / f"{event.fingerprint[:8]}-{ts}.log"
230
+ logger.info(
231
+ "Invoking Claude Code for %s (fp=%s) — log: %s",
232
+ event.source, event.fingerprint, claude_log_path,
233
+ )
198
234
 
199
235
  base_env = _os.environ.copy()
200
236
  api_env = {**base_env, "ANTHROPIC_API_KEY": cfg.anthropic_api_key} if cfg.anthropic_api_key else None
@@ -216,7 +252,9 @@ def generate_fix(
216
252
  if env is None:
217
253
  continue
218
254
  logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
219
- output, timed_out = _run_claude_attempt(cfg.claude_code_bin, prompt, env)
255
+ output, timed_out = _run_claude_attempt(
256
+ cfg.claude_code_bin, prompt, env, cwd=repo.local_path, claude_log_path=claude_log_path,
257
+ )
220
258
  if timed_out:
221
259
  logger.error("Claude Code timed out for %s", event.fingerprint)
222
260
  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