@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/.cairn/.hint-lock +1 -1
- package/.cairn/minify-map.json +6 -0
- package/.cairn/session.json +2 -2
- package/.cairn/views/244a09_generate.js +39 -107
- package/.cairn/views/2a85cc_init.js +339 -0
- package/.cairn/views/fc4a1a_add.js +599 -0
- package/lib/add.js +11 -20
- package/lib/generate.js +2 -2
- package/lib/init.js +3 -2
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +2 -2
- package/python/sentinel/fix_engine.py +42 -4
- package/python/sentinel/git_manager.py +16 -2
- package/templates/repo-configs/_example.properties +0 -3
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
|
|
286
|
+
const projectDir = path.join(workspace, name);
|
|
287
287
|
step(`[2/3] Scanning repo-configs in ${repoSlug}…`);
|
|
288
288
|
|
|
289
|
-
if (!fs.existsSync(
|
|
290
|
-
spawnSync(gitBin(), ['clone', '--depth', '1', gitUrl,
|
|
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(
|
|
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
|
|
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(
|
|
472
|
-
// Write SSH_KEY_FILE for primary repo itself
|
|
473
|
-
const primaryProps = path.join(
|
|
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 ${
|
|
486
|
-
printNextSteps(
|
|
487
|
-
await offerToStart(
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
@@ -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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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"{
|
|
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
|
|