@misterhuydo/sentinel 1.0.0 → 1.0.1

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/generate.js CHANGED
@@ -5,15 +5,21 @@ const path = require('path');
5
5
 
6
6
  // ── Per-project files ─────────────────────────────────────────────────────────
7
7
 
8
- function writeExampleProject(projectDir, codeDir, pythonBin) {
8
+ function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '') {
9
9
  const configDir = path.join(projectDir, 'config', 'log-configs');
10
10
  const repoDir = path.join(projectDir, 'config', 'repo-configs');
11
11
  fs.ensureDirSync(configDir);
12
12
  fs.ensureDirSync(repoDir);
13
13
 
14
- // Copy example config templates from bundled python/
15
14
  const tplDir = path.join(__dirname, '..', 'templates');
16
- fs.copySync(path.join(tplDir, 'sentinel.properties'), path.join(projectDir, 'config', 'sentinel.properties'));
15
+ // Inject API key into sentinel.properties if provided
16
+ let sentinelProps = fs.readFileSync(path.join(tplDir, 'sentinel.properties'), 'utf8');
17
+ if (anthropicKey) {
18
+ sentinelProps += `\n# Anthropic API key for Claude Code (headless server auth)\nANTHROPIC_API_KEY=${anthropicKey}\n`;
19
+ } else {
20
+ sentinelProps += `\n# Anthropic API key — set this if using API key auth, or leave blank for OAuth\n# ANTHROPIC_API_KEY=sk-ant-...\n`;
21
+ }
22
+ fs.writeFileSync(path.join(projectDir, 'config', 'sentinel.properties'), sentinelProps);
17
23
  fs.copySync(path.join(tplDir, 'log-configs', '_example.properties'), path.join(configDir, '_example.properties'));
18
24
  fs.copySync(path.join(tplDir, 'repo-configs', '_example.properties'), path.join(repoDir, '_example.properties'));
19
25
 
package/lib/init.js CHANGED
@@ -23,6 +23,22 @@ module.exports = async function init() {
23
23
  initial: path.join(os.homedir(), 'sentinel'),
24
24
  format: v => v.replace(/^~/, os.homedir()),
25
25
  },
26
+ {
27
+ type: 'select',
28
+ name: 'authMode',
29
+ message: 'How will Claude Code authenticate?',
30
+ choices: [
31
+ { title: 'API key (Anthropic API account — recommended for servers)', value: 'apikey' },
32
+ { title: 'Claude Pro / OAuth (will give you a URL to open in any browser)', value: 'oauth' },
33
+ { title: 'Skip (I will configure this later)', value: 'skip' },
34
+ ],
35
+ },
36
+ {
37
+ type: prev => prev === 'apikey' ? 'password' : null,
38
+ name: 'anthropicKey',
39
+ message: 'Anthropic API key (sk-ant-...)',
40
+ validate: v => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
41
+ },
26
42
  {
27
43
  type: 'confirm',
28
44
  name: 'example',
@@ -37,7 +53,7 @@ module.exports = async function init() {
37
53
  },
38
54
  ], { onCancel: () => process.exit(0) });
39
55
 
40
- const { workspace, example, systemd } = answers;
56
+ const { workspace, authMode, anthropicKey, example, systemd } = answers;
41
57
  const codeDir = path.join(workspace, 'code');
42
58
 
43
59
  // ── Python ──────────────────────────────────────────────────────────────────
@@ -80,6 +96,18 @@ module.exports = async function init() {
80
96
  installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
81
97
  installNpmGlobal('@anthropic-ai/claude-code', 'claude');
82
98
 
99
+ // ── Claude Code auth ─────────────────────────────────────────────────────────
100
+ step('Claude Code authentication…');
101
+ if (authMode === 'apikey' && anthropicKey) {
102
+ ok('API key will be written to each project\'s sentinel.properties');
103
+ } else if (authMode === 'oauth') {
104
+ console.log(chalk.yellow('\n Claude Code will print a URL — open it in any browser to complete login.\n'));
105
+ spawnSync('claude', ['--print', 'hi'], { stdio: 'inherit', env: process.env });
106
+ ok('Claude Code authenticated (OAuth token stored in ~/.claude/)');
107
+ } else {
108
+ warn('Skipping auth — run "claude" manually on this server to authenticate when ready');
109
+ }
110
+
83
111
  // ── Workspace structure ─────────────────────────────────────────────────────
84
112
  step('Creating workspace…');
85
113
  fs.ensureDirSync(workspace);
@@ -89,7 +117,7 @@ module.exports = async function init() {
89
117
  if (example) {
90
118
  step('Creating example project…');
91
119
  const exampleDir = path.join(workspace, 'my-project');
92
- writeExampleProject(exampleDir, codeDir, pythonBin);
120
+ writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '');
93
121
  ok(`Example project: ${exampleDir}`);
94
122
  }
95
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1,174 +1,176 @@
1
- """
2
- config_loader.py — Load and hot-reload all .properties config files.
3
- """
4
-
5
- import logging
6
- import signal
7
- from dataclasses import dataclass, field
8
- from pathlib import Path
9
- from typing import Optional
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- def _parse_properties(path: str) -> dict[str, str]:
15
- result = {}
16
- with open(path, encoding="utf-8") as f:
17
- for raw in f:
18
- line = raw.strip()
19
- if not line or line.startswith("#"):
20
- continue
21
- if "=" not in line:
22
- continue
23
- key, _, val = line.partition("=")
24
- key = key.strip()
25
- val = val.partition("#")[0].strip()
26
- if key:
27
- result[key] = val
28
- return result
29
-
30
-
31
- def _csv(val: str) -> list[str]:
32
- return [v.strip() for v in val.split(",") if v.strip()]
33
-
34
-
35
- # ── Typed config objects ──────────────────────────────────────────────────────
36
-
37
- @dataclass
38
- class SentinelConfig:
39
- poll_interval_seconds: int = 120
40
- smtp_host: str = ""
41
- smtp_port: int = 587
42
- smtp_user: str = ""
43
- smtp_password: str = ""
44
- report_recipients: list[str] = field(default_factory=list)
45
- report_interval_hours: int = 6
46
- state_db: str = "./sentinel.db"
47
- workspace_dir: str = "./workspace"
48
- claude_code_bin: str = "claude"
49
- github_token: str = ""
50
- fix_confidence_threshold: float = 0.7
51
- log_retention_hours: int = 48
52
-
53
-
54
- @dataclass
55
- class LogSourceConfig:
56
- name: str = "" # derived from filename stem
57
- source_type: str = "ssh"
58
- # SSH
59
- key: str = ""
60
- hosts: list[str] = field(default_factory=list)
61
- logs: list[str] = field(default_factory=list)
62
- remote_service_user: str = ""
63
- grep_filter: str = ""
64
- grep_exclude: str = ""
65
- tail: Optional[int] = None
66
- head: Optional[int] = None
67
- # Cloudflare
68
- cf_url: str = ""
69
- cf_token: str = ""
70
-
71
-
72
- @dataclass
73
- class RepoConfig:
74
- repo_name: str = "" # derived from filename stem
75
- repo_url: str = ""
76
- local_path: str = ""
77
- branch: str = "main"
78
- auto_publish: bool = False
79
- cicd_type: str = ""
80
- cicd_job_url: str = ""
81
- cicd_token: str = ""
82
-
83
-
84
- # ── Loader ────────────────────────────────────────────────────────────────────
85
-
86
- class ConfigLoader:
87
- def __init__(self, config_dir: str = "./config"):
88
- self.config_dir = Path(config_dir)
89
- self.sentinel: SentinelConfig = SentinelConfig()
90
- self.log_sources: dict[str, LogSourceConfig] = {}
91
- self.repos: dict[str, RepoConfig] = {}
92
- self.load()
93
- self._register_sighup()
94
-
95
- def load(self):
96
- self._load_sentinel()
97
- self._load_log_sources()
98
- self._load_repos()
99
- logger.info(
100
- "Config loaded: %d log-config(s), %d repo-config(s)",
101
- len(self.log_sources), len(self.repos),
102
- )
103
-
104
- def _load_sentinel(self):
105
- path = self.config_dir / "sentinel.properties"
106
- if not path.exists():
107
- logger.warning("sentinel.properties not found at %s", path)
108
- return
109
- d = _parse_properties(str(path))
110
- c = SentinelConfig()
111
- c.poll_interval_seconds = int(d.get("POLL_INTERVAL_SECONDS", 120))
112
- c.smtp_host = d.get("SMTP_HOST", "")
113
- c.smtp_port = int(d.get("SMTP_PORT", 587))
114
- c.smtp_user = d.get("SMTP_USER", "")
115
- c.smtp_password = d.get("SMTP_PASSWORD", "")
116
- c.report_recipients = _csv(d.get("REPORT_RECIPIENTS", ""))
117
- c.report_interval_hours = int(d.get("REPORT_INTERVAL_HOURS", 6))
118
- c.state_db = d.get("STATE_DB", "./sentinel.db")
119
- c.workspace_dir = d.get("WORKSPACE_DIR", "./workspace")
120
- c.claude_code_bin = d.get("CLAUDE_CODE_BIN", "claude")
121
- c.github_token = d.get("GITHUB_TOKEN", "")
122
- c.fix_confidence_threshold = float(d.get("FIX_CONFIDENCE_THRESHOLD", 0.7))
123
- c.log_retention_hours = int(d.get("LOG_RETENTION_HOURS", 48))
124
- self.sentinel = c
125
-
126
- def _load_log_sources(self):
127
- sources_dir = self.config_dir / "log-configs"
128
- if not sources_dir.exists():
129
- return
130
- self.log_sources = {}
131
- for path in sorted(sources_dir.glob("*.properties")):
132
- d = _parse_properties(str(path))
133
- s = LogSourceConfig()
134
- s.name = path.stem
135
- s.source_type = d.get("SOURCE_TYPE", "ssh").lower()
136
- s.key = d.get("KEY", "")
137
- s.hosts = _csv(d.get("HOSTS", ""))
138
- s.logs = _csv(d.get("LOGS", ""))
139
- s.remote_service_user = d.get("REMOTE_SERVICE_USER", path.stem)
140
- s.grep_filter = d.get("GREP_FILTER", "")
141
- s.grep_exclude = d.get("GREP_EXCLUDE", "")
142
- s.tail = int(d["TAIL"]) if "TAIL" in d else None
143
- s.head = int(d["HEAD"]) if "HEAD" in d else None
144
- s.cf_url = d.get("CF_URL", "")
145
- s.cf_token = d.get("CF_TOKEN", "")
146
- self.log_sources[s.name] = s
147
-
148
- def _load_repos(self):
149
- repos_dir = self.config_dir / "repo-configs"
150
- if not repos_dir.exists():
151
- return
152
- self.repos = {}
153
- for path in sorted(repos_dir.glob("*.properties")):
154
- d = _parse_properties(str(path))
155
- r = RepoConfig()
156
- r.repo_name = path.stem
157
- r.repo_url = d.get("REPO_URL", "")
158
- r.local_path = d.get("LOCAL_PATH", "")
159
- r.branch = d.get("BRANCH", "main")
160
- r.auto_publish = d.get("AUTO_PUBLISH", "false").lower() == "true"
161
- r.cicd_type = d.get("CICD_TYPE", "")
162
- r.cicd_job_url = d.get("CICD_JOB_URL", "")
163
- r.cicd_token = d.get("CICD_TOKEN", "")
164
- self.repos[r.repo_name] = r
165
-
166
- def _register_sighup(self):
167
- try:
168
- signal.signal(signal.SIGHUP, self._on_sighup)
169
- except (OSError, AttributeError):
170
- pass
171
-
172
- def _on_sighup(self, *_):
173
- logger.info("SIGHUP received — reloading config")
174
- self.load()
1
+ """
2
+ config_loader.py — Load and hot-reload all .properties config files.
3
+ """
4
+
5
+ import logging
6
+ import signal
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _parse_properties(path: str) -> dict[str, str]:
15
+ result = {}
16
+ with open(path, encoding="utf-8") as f:
17
+ for raw in f:
18
+ line = raw.strip()
19
+ if not line or line.startswith("#"):
20
+ continue
21
+ if "=" not in line:
22
+ continue
23
+ key, _, val = line.partition("=")
24
+ key = key.strip()
25
+ val = val.partition("#")[0].strip()
26
+ if key:
27
+ result[key] = val
28
+ return result
29
+
30
+
31
+ def _csv(val: str) -> list[str]:
32
+ return [v.strip() for v in val.split(",") if v.strip()]
33
+
34
+
35
+ # ── Typed config objects ──────────────────────────────────────────────────────
36
+
37
+ @dataclass
38
+ class SentinelConfig:
39
+ poll_interval_seconds: int = 120
40
+ smtp_host: str = ""
41
+ smtp_port: int = 587
42
+ smtp_user: str = ""
43
+ smtp_password: str = ""
44
+ report_recipients: list[str] = field(default_factory=list)
45
+ report_interval_hours: int = 6
46
+ state_db: str = "./sentinel.db"
47
+ workspace_dir: str = "./workspace"
48
+ claude_code_bin: str = "claude"
49
+ github_token: str = ""
50
+ fix_confidence_threshold: float = 0.7
51
+ log_retention_hours: int = 48
52
+ anthropic_api_key: str = ""
53
+
54
+
55
+ @dataclass
56
+ class LogSourceConfig:
57
+ name: str = "" # derived from filename stem
58
+ source_type: str = "ssh"
59
+ # SSH
60
+ key: str = ""
61
+ hosts: list[str] = field(default_factory=list)
62
+ logs: list[str] = field(default_factory=list)
63
+ remote_service_user: str = ""
64
+ grep_filter: str = ""
65
+ grep_exclude: str = ""
66
+ tail: Optional[int] = None
67
+ head: Optional[int] = None
68
+ # Cloudflare
69
+ cf_url: str = ""
70
+ cf_token: str = ""
71
+
72
+
73
+ @dataclass
74
+ class RepoConfig:
75
+ repo_name: str = "" # derived from filename stem
76
+ repo_url: str = ""
77
+ local_path: str = ""
78
+ branch: str = "main"
79
+ auto_publish: bool = False
80
+ cicd_type: str = ""
81
+ cicd_job_url: str = ""
82
+ cicd_token: str = ""
83
+
84
+
85
+ # ── Loader ────────────────────────────────────────────────────────────────────
86
+
87
+ class ConfigLoader:
88
+ def __init__(self, config_dir: str = "./config"):
89
+ self.config_dir = Path(config_dir)
90
+ self.sentinel: SentinelConfig = SentinelConfig()
91
+ self.log_sources: dict[str, LogSourceConfig] = {}
92
+ self.repos: dict[str, RepoConfig] = {}
93
+ self.load()
94
+ self._register_sighup()
95
+
96
+ def load(self):
97
+ self._load_sentinel()
98
+ self._load_log_sources()
99
+ self._load_repos()
100
+ logger.info(
101
+ "Config loaded: %d log-config(s), %d repo-config(s)",
102
+ len(self.log_sources), len(self.repos),
103
+ )
104
+
105
+ def _load_sentinel(self):
106
+ path = self.config_dir / "sentinel.properties"
107
+ if not path.exists():
108
+ logger.warning("sentinel.properties not found at %s", path)
109
+ return
110
+ d = _parse_properties(str(path))
111
+ c = SentinelConfig()
112
+ c.poll_interval_seconds = int(d.get("POLL_INTERVAL_SECONDS", 120))
113
+ c.smtp_host = d.get("SMTP_HOST", "")
114
+ c.smtp_port = int(d.get("SMTP_PORT", 587))
115
+ c.smtp_user = d.get("SMTP_USER", "")
116
+ c.smtp_password = d.get("SMTP_PASSWORD", "")
117
+ c.report_recipients = _csv(d.get("REPORT_RECIPIENTS", ""))
118
+ c.report_interval_hours = int(d.get("REPORT_INTERVAL_HOURS", 6))
119
+ c.state_db = d.get("STATE_DB", "./sentinel.db")
120
+ c.workspace_dir = d.get("WORKSPACE_DIR", "./workspace")
121
+ c.claude_code_bin = d.get("CLAUDE_CODE_BIN", "claude")
122
+ c.github_token = d.get("GITHUB_TOKEN", "")
123
+ c.fix_confidence_threshold = float(d.get("FIX_CONFIDENCE_THRESHOLD", 0.7))
124
+ c.log_retention_hours = int(d.get("LOG_RETENTION_HOURS", 48))
125
+ c.anthropic_api_key = d.get("ANTHROPIC_API_KEY", "")
126
+ self.sentinel = c
127
+
128
+ def _load_log_sources(self):
129
+ sources_dir = self.config_dir / "log-configs"
130
+ if not sources_dir.exists():
131
+ return
132
+ self.log_sources = {}
133
+ for path in sorted(sources_dir.glob("*.properties")):
134
+ d = _parse_properties(str(path))
135
+ s = LogSourceConfig()
136
+ s.name = path.stem
137
+ s.source_type = d.get("SOURCE_TYPE", "ssh").lower()
138
+ s.key = d.get("KEY", "")
139
+ s.hosts = _csv(d.get("HOSTS", ""))
140
+ s.logs = _csv(d.get("LOGS", ""))
141
+ s.remote_service_user = d.get("REMOTE_SERVICE_USER", path.stem)
142
+ s.grep_filter = d.get("GREP_FILTER", "")
143
+ s.grep_exclude = d.get("GREP_EXCLUDE", "")
144
+ s.tail = int(d["TAIL"]) if "TAIL" in d else None
145
+ s.head = int(d["HEAD"]) if "HEAD" in d else None
146
+ s.cf_url = d.get("CF_URL", "")
147
+ s.cf_token = d.get("CF_TOKEN", "")
148
+ self.log_sources[s.name] = s
149
+
150
+ def _load_repos(self):
151
+ repos_dir = self.config_dir / "repo-configs"
152
+ if not repos_dir.exists():
153
+ return
154
+ self.repos = {}
155
+ for path in sorted(repos_dir.glob("*.properties")):
156
+ d = _parse_properties(str(path))
157
+ r = RepoConfig()
158
+ r.repo_name = path.stem
159
+ r.repo_url = d.get("REPO_URL", "")
160
+ r.local_path = d.get("LOCAL_PATH", "")
161
+ r.branch = d.get("BRANCH", "main")
162
+ r.auto_publish = d.get("AUTO_PUBLISH", "false").lower() == "true"
163
+ r.cicd_type = d.get("CICD_TYPE", "")
164
+ r.cicd_job_url = d.get("CICD_JOB_URL", "")
165
+ r.cicd_token = d.get("CICD_TOKEN", "")
166
+ self.repos[r.repo_name] = r
167
+
168
+ def _register_sighup(self):
169
+ try:
170
+ signal.signal(signal.SIGHUP, self._on_sighup)
171
+ except (OSError, AttributeError):
172
+ pass
173
+
174
+ def _on_sighup(self, *_):
175
+ logger.info("SIGHUP received — reloading config")
176
+ self.load()
@@ -1,123 +1,127 @@
1
- """
2
- fix_engine.py — Generate code fixes via Claude Code (headless).
3
-
4
- Invokes: claude --print "<prompt>" 2>&1
5
-
6
- Cairn MCP context is fetched automatically by Claude Code via its MCP tool
7
- connection — Sentinel does not need to query or inject it explicitly.
8
- """
9
-
10
- import logging
11
- import re
12
- import subprocess
13
- import textwrap
14
- from pathlib import Path
15
-
16
- from .config_loader import RepoConfig, SentinelConfig
17
- from .log_parser import ErrorEvent
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
- SUBPROCESS_TIMEOUT = 120
22
- MAX_FILES_IN_PATCH = 5
23
- MAX_LINES_IN_PATCH = 200
24
-
25
- _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
26
- _DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
27
-
28
-
29
- def _build_prompt(event: ErrorEvent, repo: RepoConfig, log_file: Path) -> str:
30
- return textwrap.dedent(f"""\
31
- You are fixing a production bug in the repository at {repo.local_path}.
32
- Repository: {repo.repo_name}
33
-
34
- LOG FILE: {log_file}
35
- Read this file first. It contains the last 48h of logs from {event.source} —
36
- use it to understand the frequency, surrounding context, and any warnings
37
- that preceded this error.
38
-
39
- ERROR fingerprint to fix (from {event.source}):
40
- {event.full_text()}
41
-
42
- Task:
43
- 1. Read the log file above to understand what led up to this error.
44
- 2. Use your available tools to explore the codebase and identify the root cause.
45
- 3. Output ONLY a unified diff patch (git diff format) fixing the issue.
46
- 4. Do not explain. Output only the patch.
47
- 5. If you cannot determine a safe fix, output: SKIP: <reason>
48
- """)
49
-
50
-
51
- def _extract_patch(output: str) -> str | None:
52
- m = _DIFF_BLOCK.search(output)
53
- if m:
54
- return m.group(1).strip()
55
- if _DIFF_HEADER.search(output):
56
- return output.strip()
57
- return None
58
-
59
-
60
- def _validate_patch(patch: str) -> tuple[bool, str]:
61
- files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
62
- lines_changed = len([
63
- l for l in patch.splitlines()
64
- if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
65
- ])
66
- if files_changed > MAX_FILES_IN_PATCH:
67
- return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
68
- if lines_changed > MAX_LINES_IN_PATCH:
69
- return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
70
- return True, ""
71
-
72
-
73
- def generate_fix(
74
- event: ErrorEvent,
75
- repo: RepoConfig,
76
- cfg: SentinelConfig,
77
- patches_dir: Path,
78
- ) -> tuple[str, Path | None]:
79
- """
80
- Generate a fix for the given error event.
81
-
82
- Returns:
83
- (status, patch_path)
84
- status: "patch" | "skip" | "error"
85
- """
86
- log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
87
- prompt = _build_prompt(event, repo, log_file)
88
-
89
- logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
90
- try:
91
- result = subprocess.run(
92
- [cfg.claude_code_bin, "--print", prompt],
93
- capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT,
94
- )
95
- except subprocess.TimeoutExpired:
96
- logger.error("Claude Code timed out for %s", event.fingerprint)
97
- return "error", None
98
- except FileNotFoundError:
99
- logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
100
- return "error", None
101
-
102
- output = (result.stdout or "") + (result.stderr or "")
103
-
104
- if output.strip().upper().startswith("SKIP:"):
105
- reason = output.strip()[5:].strip()
106
- logger.info("Claude skipped fix for %s: %s", event.fingerprint, reason)
107
- return "skip", None
108
-
109
- patch = _extract_patch(output)
110
- if not patch:
111
- logger.warning("No patch found in Claude output for %s", event.fingerprint)
112
- return "error", None
113
-
114
- ok, reason = _validate_patch(patch)
115
- if not ok:
116
- logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
117
- return "skip", None
118
-
119
- patches_dir.mkdir(parents=True, exist_ok=True)
120
- patch_path = patches_dir / f"{event.fingerprint}.diff"
121
- patch_path.write_text(patch, encoding="utf-8")
122
- logger.info("Patch written to %s", patch_path)
123
- return "patch", patch_path
1
+ """
2
+ fix_engine.py — Generate code fixes via Claude Code (headless).
3
+
4
+ Invokes: claude --print "<prompt>" 2>&1
5
+
6
+ Cairn MCP context is fetched automatically by Claude Code via its MCP tool
7
+ connection — Sentinel does not need to query or inject it explicitly.
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ import subprocess
13
+ import textwrap
14
+ from pathlib import Path
15
+
16
+ from .config_loader import RepoConfig, SentinelConfig
17
+ from .log_parser import ErrorEvent
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ SUBPROCESS_TIMEOUT = 120
22
+ MAX_FILES_IN_PATCH = 5
23
+ MAX_LINES_IN_PATCH = 200
24
+
25
+ _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
26
+ _DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
27
+
28
+
29
+ def _build_prompt(event: ErrorEvent, repo: RepoConfig, log_file: Path) -> str:
30
+ return textwrap.dedent(f"""\
31
+ You are fixing a production bug in the repository at {repo.local_path}.
32
+ Repository: {repo.repo_name}
33
+
34
+ LOG FILE: {log_file}
35
+ Read this file first. It contains the last 48h of logs from {event.source} —
36
+ use it to understand the frequency, surrounding context, and any warnings
37
+ that preceded this error.
38
+
39
+ ERROR fingerprint to fix (from {event.source}):
40
+ {event.full_text()}
41
+
42
+ Task:
43
+ 1. Read the log file above to understand what led up to this error.
44
+ 2. Use your available tools to explore the codebase and identify the root cause.
45
+ 3. Output ONLY a unified diff patch (git diff format) fixing the issue.
46
+ 4. Do not explain. Output only the patch.
47
+ 5. If you cannot determine a safe fix, output: SKIP: <reason>
48
+ """)
49
+
50
+
51
+ def _extract_patch(output: str) -> str | None:
52
+ m = _DIFF_BLOCK.search(output)
53
+ if m:
54
+ return m.group(1).strip()
55
+ if _DIFF_HEADER.search(output):
56
+ return output.strip()
57
+ return None
58
+
59
+
60
+ def _validate_patch(patch: str) -> tuple[bool, str]:
61
+ files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
62
+ lines_changed = len([
63
+ l for l in patch.splitlines()
64
+ if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
65
+ ])
66
+ if files_changed > MAX_FILES_IN_PATCH:
67
+ return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
68
+ if lines_changed > MAX_LINES_IN_PATCH:
69
+ return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
70
+ return True, ""
71
+
72
+
73
+ def generate_fix(
74
+ event: ErrorEvent,
75
+ repo: RepoConfig,
76
+ cfg: SentinelConfig,
77
+ patches_dir: Path,
78
+ ) -> tuple[str, Path | None]:
79
+ """
80
+ Generate a fix for the given error event.
81
+
82
+ Returns:
83
+ (status, patch_path)
84
+ status: "patch" | "skip" | "error"
85
+ """
86
+ log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
87
+ prompt = _build_prompt(event, repo, log_file)
88
+
89
+ logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
90
+ import os as _os
91
+ env = _os.environ.copy()
92
+ if cfg.anthropic_api_key:
93
+ env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
94
+ try:
95
+ result = subprocess.run(
96
+ [cfg.claude_code_bin, "--print", prompt],
97
+ capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
98
+ )
99
+ except subprocess.TimeoutExpired:
100
+ logger.error("Claude Code timed out for %s", event.fingerprint)
101
+ return "error", None
102
+ except FileNotFoundError:
103
+ logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
104
+ return "error", None
105
+
106
+ output = (result.stdout or "") + (result.stderr or "")
107
+
108
+ if output.strip().upper().startswith("SKIP:"):
109
+ reason = output.strip()[5:].strip()
110
+ logger.info("Claude skipped fix for %s: %s", event.fingerprint, reason)
111
+ return "skip", None
112
+
113
+ patch = _extract_patch(output)
114
+ if not patch:
115
+ logger.warning("No patch found in Claude output for %s", event.fingerprint)
116
+ return "error", None
117
+
118
+ ok, reason = _validate_patch(patch)
119
+ if not ok:
120
+ logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
121
+ return "skip", None
122
+
123
+ patches_dir.mkdir(parents=True, exist_ok=True)
124
+ patch_path = patches_dir / f"{event.fingerprint}.diff"
125
+ patch_path.write_text(patch, encoding="utf-8")
126
+ logger.info("Patch written to %s", patch_path)
127
+ return "patch", patch_path