@misterhuydo/sentinel 1.0.6 → 1.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.6",
3
+ "version": "1.0.10",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -51,6 +51,7 @@ class SentinelConfig:
51
51
  fix_confidence_threshold: float = 0.7
52
52
  log_retention_hours: int = 48
53
53
  anthropic_api_key: str = ""
54
+ marker_confirm_hours: int = 24 # quiet period before confirming a fix
54
55
 
55
56
 
56
57
  @dataclass
@@ -104,11 +105,21 @@ class ConfigLoader:
104
105
  )
105
106
 
106
107
  def _load_sentinel(self):
108
+ # Load workspace-level config first (~/sentinel/sentinel.properties),
109
+ # then overlay per-project config so project values win.
110
+ d: dict[str, str] = {}
111
+ workspace_props = self.config_dir.parent.parent / "sentinel.properties"
112
+ if workspace_props.exists():
113
+ d.update(_parse_properties(str(workspace_props)))
114
+ logger.debug("Loaded workspace config from %s", workspace_props)
115
+
107
116
  path = self.config_dir / "sentinel.properties"
108
117
  if not path.exists():
109
- logger.warning("sentinel.properties not found at %s", path)
110
- return
111
- d = _parse_properties(str(path))
118
+ if not d:
119
+ logger.warning("sentinel.properties not found at %s", path)
120
+ else:
121
+ d.update(_parse_properties(str(path)))
122
+
112
123
  c = SentinelConfig()
113
124
  c.poll_interval_seconds = int(d.get("POLL_INTERVAL_SECONDS", 120))
114
125
  c.smtp_host = d.get("SMTP_HOST", "")
@@ -125,6 +136,7 @@ class ConfigLoader:
125
136
  c.fix_confidence_threshold = float(d.get("FIX_CONFIDENCE_THRESHOLD", 0.7))
126
137
  c.log_retention_hours = int(d.get("LOG_RETENTION_HOURS", 48))
127
138
  c.anthropic_api_key = d.get("ANTHROPIC_API_KEY", "")
139
+ c.marker_confirm_hours = int(d.get("MARKER_CONFIRM_HOURS", 24))
128
140
  self.sentinel = c
129
141
 
130
142
  def _load_log_sources(self):
@@ -26,26 +26,59 @@ _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
26
26
  _DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
27
27
 
28
28
 
29
- def _build_prompt(event, repo: RepoConfig, log_file) -> str:
29
+ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers: list[str] = None) -> str:
30
30
  if log_file and log_file.exists():
31
31
  ctx = (
32
- "LOG FILE: " + str(log_file) + "\n"
32
+ "LOG FILE: " + str(log_file) + "
33
+ "
33
34
  "Read this file first -- it contains the last 48h of logs from "
34
- + event.source + ".\n"
35
+ + event.source + ".
36
+ "
35
37
  "Use it to understand frequency, context, and preceding warnings."
36
38
  )
37
39
  step1 = "Read the log file above to understand what led up to this error."
38
40
  else:
39
41
  ctx = (
40
- "SOURCE: " + event.source + "\n"
42
+ "SOURCE: " + event.source + "
43
+ "
41
44
  "No rolling log file available. The full issue description is below."
42
45
  )
43
46
  step1 = "Use the issue description above as your primary context."
44
47
 
48
+ marker_label = marker + " sentinel-auto-fix [safe to remove after verification]"
49
+ marker_instruction = "
50
+ ".join([
51
+ "For EVERY method and constructor you modify, add this as the FIRST executable line:",
52
+ f" Java/Kotlin : log.info("{marker_label}");",
53
+ f" Python : logger.info("{marker_label}")",
54
+ f" Node.js : logger.info("{marker_label}")",
55
+ "Use the logger already present in the file. Do not add new imports.",
56
+ "This applies to ALL modified methods and constructors without exception.",
57
+ ])
58
+
59
+ cleanup = ""
60
+ if stale_markers:
61
+ marker_list = "
62
+ ".join(f" - {m}" for m in stale_markers)
63
+ cleanup = (
64
+ "CLEANUP (do this first, before the fix):
65
+ "
66
+ "Remove any log lines containing these stale Sentinel markers from the codebase:
67
+ "
68
+ + marker_list + "
69
+ "
70
+ "Commit the cleanup separately with message: 'chore(sentinel): remove stale markers'
71
+ "
72
+ )
73
+
45
74
  lines_out = [
46
75
  f"You are fixing a production bug in the repository at {repo.local_path}.",
47
76
  f"Repository: {repo.repo_name}",
48
77
  "",
78
+ ]
79
+ if cleanup:
80
+ lines_out += [cleanup, ""]
81
+ lines_out += [
49
82
  ctx,
50
83
  "",
51
84
  f"ISSUE TO FIX (from {event.source}):",
@@ -54,11 +87,13 @@ def _build_prompt(event, repo: RepoConfig, log_file) -> str:
54
87
  "Task:",
55
88
  f"1. {step1}",
56
89
  "2. Use your available tools to explore the codebase and identify the root cause.",
57
- "3. Output ONLY a unified diff patch (git diff format) fixing the issue.",
58
- "4. Do not explain. Output only the patch.",
59
- "5. If you cannot determine a safe fix, output: SKIP: <reason>",
90
+ f"3. {marker_instruction}",
91
+ "4. Output ONLY a unified diff patch (git diff format) fixing the issue.",
92
+ "5. Do not explain. Output only the patch.",
93
+ "6. If you cannot determine a safe fix, output: SKIP: <reason>",
60
94
  ]
61
- return "\n".join(lines_out)
95
+ return "
96
+ ".join(lines_out)
62
97
 
63
98
  def _validate_patch(patch: str) -> tuple[bool, str]:
64
99
  files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
@@ -104,30 +139,30 @@ def generate_fix(
104
139
  )
105
140
  except subprocess.TimeoutExpired:
106
141
  logger.error("Claude Code timed out for %s", event.fingerprint)
107
- return "error", None
142
+ return "error", None, ""
108
143
  except FileNotFoundError:
109
144
  logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
110
- return "error", None
145
+ return "error", None, ""
111
146
 
112
147
  output = (result.stdout or "") + (result.stderr or "")
113
148
 
114
149
  if output.strip().upper().startswith("SKIP:"):
115
150
  reason = output.strip()[5:].strip()
116
151
  logger.info("Claude skipped fix for %s: %s", event.fingerprint, reason)
117
- return "skip", None
152
+ return "skip", None, ""
118
153
 
119
154
  patch = _extract_patch(output)
120
155
  if not patch:
121
156
  logger.warning("No patch found in Claude output for %s", event.fingerprint)
122
- return "error", None
157
+ return "error", None, ""
123
158
 
124
159
  ok, reason = _validate_patch(patch)
125
160
  if not ok:
126
161
  logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
127
- return "skip", None
162
+ return "skip", None, ""
128
163
 
129
164
  patches_dir.mkdir(parents=True, exist_ok=True)
130
165
  patch_path = patches_dir / f"{event.fingerprint}.diff"
131
166
  patch_path.write_text(patch, encoding="utf-8")
132
167
  logger.info("Patch written to %s", patch_path)
133
- return "patch", patch_path
168
+ return "patch", patch_path, marker
@@ -1,131 +1,146 @@
1
- """
2
- issue_watcher.py — Scan the issues/ directory for manually-submitted bug reports.
3
-
4
- Admins drop plain-text or markdown files into <project>/issues/.
5
- Each file is treated as a fix request. Processed files are archived to issues/.done/.
6
-
7
- File format (TARGET_REPO header is optional):
8
-
9
- TARGET_REPO: my-repo-name
10
-
11
- Short summary of the problem (becomes the email subject line)
12
-
13
- Any details: customer feedback, stack traces, screenshots text, etc.
14
- If TARGET_REPO is omitted and only one repo is configured, it is used automatically.
15
- """
16
-
17
- import hashlib
18
- import logging
19
- import time
20
- from dataclasses import dataclass, field
21
- from datetime import datetime, timezone
22
- from pathlib import Path
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
- _TARGET_REPO_PREFIX = "TARGET_REPO:"
27
-
28
-
29
- @dataclass
30
- class IssueEvent:
31
- """
32
- A fix request sourced from the issues/ directory.
33
- Implements the same interface as ErrorEvent so it can flow through
34
- the same fix pipeline (_handle_error / generate_fix / git_manager).
35
- """
36
- source: str # "issues/<filename>" — shown in emails and logs
37
- issue_file: Path # full path, used for archiving after processing
38
- message: str # first non-blank body line — used as subject summary
39
- body: str # full file content (the issue description)
40
- target_repo: str # explicit TARGET_REPO value, or "" for auto-select
41
- fingerprint: str = ""
42
- severity: str = "ERROR"
43
- timestamp: str = ""
44
-
45
- # Compatibility fields matching ErrorEvent interface
46
- level: str = "ERROR"
47
- thread: str = ""
48
- logger_name: str = ""
49
- stack_trace: list[str] = field(default_factory=list)
50
- log_file: str = ""
51
-
52
- def __post_init__(self):
53
- if not self.fingerprint:
54
- raw = f"issue:{self.source}:{self.message[:200]}"
55
- self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
56
- if not self.timestamp:
57
- self.timestamp = datetime.now(timezone.utc).isoformat()
58
- if not self.stack_trace:
59
- self.stack_trace = self.body.splitlines()
60
-
61
- @property
62
- def is_infra_issue(self) -> bool:
63
- return False
64
-
65
- def short_summary(self) -> str:
66
- return self.message[:120]
67
-
68
- def full_text(self) -> str:
69
- return self.body
70
-
71
-
72
- def scan_issues(project_dir: Path) -> list[IssueEvent]:
73
- """
74
- Return all pending issue files from <project_dir>/issues/.
75
- Files starting with '.' and files inside .done/ are skipped.
76
- """
77
- issues_dir = project_dir / "issues"
78
- if not issues_dir.exists():
79
- return []
80
-
81
- events = []
82
- for f in sorted(issues_dir.iterdir()):
83
- if not f.is_file() or f.name.startswith("."):
84
- continue
85
-
86
- try:
87
- content = f.read_text(encoding="utf-8", errors="replace").strip()
88
- except OSError as e:
89
- logger.error("Cannot read issue file %s: %s", f, e)
90
- continue
91
-
92
- if not content:
93
- continue
94
-
95
- lines = content.splitlines()
96
- target_repo = ""
97
- body_start = 0
98
-
99
- # Parse optional TARGET_REPO: header (must be the first non-blank line)
100
- for i, line in enumerate(lines):
101
- stripped = line.strip()
102
- if stripped.upper().startswith(_TARGET_REPO_PREFIX):
103
- target_repo = stripped[len(_TARGET_REPO_PREFIX):].strip()
104
- body_start = i + 1
105
- elif stripped:
106
- break
107
-
108
- body = "\n".join(lines[body_start:]).strip() or content
109
- message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
110
-
111
- events.append(IssueEvent(
112
- source=f"issues/{f.name}",
113
- issue_file=f,
114
- message=message,
115
- body=body,
116
- target_repo=target_repo,
117
- ))
118
- logger.info("Found issue: %s (target_repo=%r)", f.name, target_repo or "auto")
119
-
120
- return events
121
-
122
-
123
- def mark_done(issue_file: Path) -> None:
124
- """Archive a processed issue to issues/.done/ regardless of outcome."""
125
- done_dir = issue_file.parent / ".done"
126
- done_dir.mkdir(exist_ok=True)
127
- dest = done_dir / issue_file.name
128
- if dest.exists():
129
- dest = done_dir / f"{issue_file.stem}-{int(time.time())}{issue_file.suffix}"
130
- issue_file.rename(dest)
131
- logger.info("Issue archived: %s -> .done/%s", issue_file.name, dest.name)
1
+ """
2
+ issue_watcher.py — Scan the issues/ directory for manually-submitted bug reports.
3
+
4
+ Admins drop plain-text or markdown files into <project>/issues/.
5
+ Each file is treated as a fix request. Processed files are archived to issues/.done/.
6
+
7
+ File format (TARGET_REPO header is optional):
8
+
9
+ TARGET_REPO: my-repo-name
10
+
11
+ Short summary of the problem (becomes the email subject line)
12
+
13
+ Any details: customer feedback, stack traces, screenshots text, etc.
14
+ If TARGET_REPO is omitted and only one repo is configured, it is used automatically.
15
+ """
16
+
17
+ import hashlib
18
+ import logging
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _TARGET_REPO_PREFIX = "TARGET_REPO:"
27
+
28
+
29
+ @dataclass
30
+ class IssueEvent:
31
+ """
32
+ A fix request sourced from the issues/ directory.
33
+ Implements the same interface as ErrorEvent so it can flow through
34
+ the same fix pipeline (_handle_error / generate_fix / git_manager).
35
+ """
36
+ source: str # "issues/<filename>" — shown in emails and logs
37
+ issue_file: Path # full path, used for archiving after processing
38
+ message: str # first non-blank body line — used as subject summary
39
+ body: str # full file content (the issue description)
40
+ target_repo: str # explicit TARGET_REPO value, or "" for auto-select
41
+ fingerprint: str = ""
42
+ severity: str = "ERROR"
43
+ timestamp: str = ""
44
+
45
+ # Compatibility fields matching ErrorEvent interface
46
+ level: str = "ERROR"
47
+ thread: str = ""
48
+ logger_name: str = ""
49
+ stack_trace: list[str] = field(default_factory=list)
50
+ log_file: str = ""
51
+
52
+ def __post_init__(self):
53
+ if not self.fingerprint:
54
+ raw = f"issue:{self.source}:{self.message[:200]}"
55
+ self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
56
+ if not self.timestamp:
57
+ self.timestamp = datetime.now(timezone.utc).isoformat()
58
+ if not self.stack_trace:
59
+ self.stack_trace = self.body.splitlines()
60
+
61
+ @property
62
+ def is_infra_issue(self) -> bool:
63
+ return False
64
+
65
+ def short_summary(self) -> str:
66
+ return self.message[:120]
67
+
68
+ def full_text(self) -> str:
69
+ return self.body
70
+
71
+
72
+ # Binary extensions Sentinel will never try to process
73
+ _BINARY_EXTENSIONS = {
74
+ ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z",
75
+ ".jar", ".war", ".ear", ".class",
76
+ ".exe", ".dll", ".so", ".bin", ".pyc",
77
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx",
78
+ ".mp3", ".mp4", ".avi", ".mov",
79
+ }
80
+
81
+
82
+ def scan_issues(project_dir: Path) -> list[IssueEvent]:
83
+ """
84
+ Return all pending issue files from <project_dir>/issues/.
85
+
86
+ Accepts text, markdown, logs, images, JSON — anything Claude can read.
87
+ Skips dotfiles, archives, and compiled binaries.
88
+ """
89
+ issues_dir = project_dir / "issues"
90
+ if not issues_dir.exists():
91
+ return []
92
+
93
+ events = []
94
+ for f in sorted(issues_dir.iterdir()):
95
+ if not f.is_file() or f.name.startswith("."):
96
+ continue
97
+ if f.suffix.lower() in _BINARY_EXTENSIONS:
98
+ logger.debug("Skipping binary issue file: %s", f.name)
99
+ continue
100
+
101
+ try:
102
+ content = f.read_text(encoding="utf-8", errors="replace").strip()
103
+ except OSError as e:
104
+ logger.error("Cannot read issue file %s: %s", f, e)
105
+ continue
106
+
107
+ if not content:
108
+ continue
109
+
110
+ lines = content.splitlines()
111
+ target_repo = ""
112
+ body_start = 0
113
+
114
+ # Parse optional TARGET_REPO: header (must be the first non-blank line)
115
+ for i, line in enumerate(lines):
116
+ stripped = line.strip()
117
+ if stripped.upper().startswith(_TARGET_REPO_PREFIX):
118
+ target_repo = stripped[len(_TARGET_REPO_PREFIX):].strip()
119
+ body_start = i + 1
120
+ elif stripped:
121
+ break
122
+
123
+ body = "\n".join(lines[body_start:]).strip() or content
124
+ message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
125
+
126
+ events.append(IssueEvent(
127
+ source=f"issues/{f.name}",
128
+ issue_file=f,
129
+ message=message,
130
+ body=body,
131
+ target_repo=target_repo,
132
+ ))
133
+ logger.info("Found issue: %s (target_repo=%r)", f.name, target_repo or "auto")
134
+
135
+ return events
136
+
137
+
138
+ def mark_done(issue_file: Path) -> None:
139
+ """Archive a processed issue to issues/.done/ regardless of outcome."""
140
+ done_dir = issue_file.parent / ".done"
141
+ done_dir.mkdir(exist_ok=True)
142
+ dest = done_dir / issue_file.name
143
+ if dest.exists():
144
+ dest = done_dir / f"{issue_file.stem}-{int(time.time())}{issue_file.suffix}"
145
+ issue_file.rename(dest)
146
+ logger.info("Issue archived: %s -> .done/%s", issue_file.name, dest.name)