@misterhuydo/sentinel 1.4.89 → 1.4.91

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.
@@ -0,0 +1,448 @@
1
+ """
2
+ sentinel_dev.py — Patch, the autonomous developer agent for Sentinel self-improvement.
3
+
4
+ Patch runs alongside Boss but independently. It watches dev-tasks/ for
5
+ requests to improve Sentinel itself — new features, bug fixes, refactors — and
6
+ executes them by running Claude Code against the Sentinel source repository.
7
+
8
+ Patch is an internal actor — humans never interact with it directly. All communication
9
+ goes through Boss, who qualifies Patch's outputs before surfacing anything to users.
10
+
11
+ Invocation sources:
12
+ - Boss (dev_task tool) → slack-<uuid>.txt in dev-tasks/
13
+ - Fix engine (BOSS_ESCALATE output) → bot-<fp>-<ts>.txt in dev-tasks/
14
+ - Self-repair (log watcher) → self-<fp>-<ts>.txt in dev-tasks/
15
+ - Admin (manual file drop) → any .txt in dev-tasks/
16
+
17
+ After a successful task:
18
+ - Commits changes to the Sentinel source repo
19
+ - Changes are live immediately (Sentinel loads Python directly from source repo)
20
+ - Human reviews git log periodically → bumps version → npm publish → auto-upgrade distributes
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import os
26
+ import re
27
+ import time
28
+
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+ from .config_loader import SentinelConfig
33
+ from .dev_watcher import DevTask
34
+ from .fix_engine import _claude_cmd, _run_claude_attempt, _write_claude_log, _is_auth_error
35
+ from .notify import slack_alert
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Internal name for the dev agent — used in logs and inter-agent communication
40
+ PATCH_NAME = "Patch"
41
+
42
+ # Dev tasks are bigger than fix tasks — allow 15 minutes
43
+ _DEV_TIMEOUT = 900
44
+
45
+ _BOSS_ESCALATE_PREFIX = "BOSS_ESCALATE:"
46
+
47
+
48
+ def _build_dev_prompt(task: DevTask, repo_path: str, past_outcomes: list | None = None) -> str:
49
+ submitted = f"Submitted by: <@{task.submitter_user_id}>" if task.submitter_user_id else ""
50
+ escalation_ctx = ""
51
+ if task.source_fingerprint:
52
+ escalation_ctx = (
53
+ f"\nThis task was escalated by the fix engine (error fingerprint: "
54
+ f"{task.source_fingerprint}) because it required a change to Sentinel itself.\n"
55
+ )
56
+
57
+ memory_ctx = ""
58
+ if past_outcomes:
59
+ lines = ["━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
60
+ "MEMORY — SIMILAR PAST TASKS (learn from these)",
61
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
62
+ for o in past_outcomes:
63
+ lines.append(
64
+ f"[{o['recorded_at'][:10]}] {o['status'].upper()} — {o['task_type']}: "
65
+ f"{o['description'][:120]}"
66
+ )
67
+ if o.get("outcome_note"):
68
+ lines.append(f" Outcome: {o['outcome_note'][:200]}")
69
+ if o.get("files_changed"):
70
+ lines.append(f" Files: {o['files_changed']}")
71
+ lines.append(
72
+ "Use these as reference: repeat what worked, avoid what failed or was skipped."
73
+ )
74
+ memory_ctx = "\n".join(lines) + "\n\n"
75
+
76
+ return (
77
+ f"You are Patch — a fully autonomous AI developer responsible for the\n"
78
+ f"resiliency, stability, and continuous improvement of the Sentinel DevOps system.\n"
79
+ f"Sentinel source repository: {repo_path}\n"
80
+ f"{submitted}\n"
81
+ f"{escalation_ctx}\n"
82
+ f"{memory_ctx}"
83
+ f"TASK TYPE: {task.task_type}\n"
84
+ f"TASK:\n{task.body}\n\n"
85
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
86
+ f"YOUR ROLE AND AUTHORITY\n"
87
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
88
+ f"You have FULL AUTONOMY within Sentinel's operational scope. This means:\n"
89
+ f"- Modify any source file in this repo (Python, JS, configs, templates)\n"
90
+ f"- Edit config files (sentinel.properties, log-configs/, repo-configs/)\n"
91
+ f"- Install OS packages or CLI tools needed to make Sentinel work\n"
92
+ f" (e.g. git, curl, jq, common CI/CD tools — use dnf/yum/apt as appropriate)\n"
93
+ f"- Run scripts, restart services, or apply system-level fixes within scope\n"
94
+ f"- Commit and push changes to the Sentinel source repository\n"
95
+ f"You do NOT need permission for any of the above. Act, don't ask.\n"
96
+ f"\n"
97
+ f"You work WITH Sentinel Boss, not under it. The relationship:\n"
98
+ f" Humans → Boss (Boss obeys humans, surfaces your work to humans)\n"
99
+ f" Boss ↔ Patch (peer agents — you can ask Boss, Boss can task you)\n"
100
+ f" Patch → Sentinel (full autonomy over Sentinel's codebase and all instances)\n"
101
+ f"Humans never interact with you directly — Boss is their only interface.\n"
102
+ f"\n"
103
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
104
+ f"SENTINEL CODEBASE OVERVIEW\n"
105
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
106
+ f"- Entry point: sentinel/main.py (asyncio poll loop + dev poll loop)\n"
107
+ f"- Config: sentinel/config_loader.py (SentinelConfig, LogSourceConfig, RepoConfig)\n"
108
+ f"- Slack bot + tools: sentinel/sentinel_boss.py\n"
109
+ f"- Fix engine: sentinel/fix_engine.py (Claude Code subprocess runner)\n"
110
+ f"- Git ops: sentinel/git_manager.py\n"
111
+ f"- Log fetching: sentinel/log_fetcher.py, sentinel/log_syncer.py\n"
112
+ f"- Log parsing: sentinel/log_parser.py\n"
113
+ f"- Issue queue: sentinel/issue_watcher.py\n"
114
+ f"- Dev task queue + self-repair: sentinel/dev_watcher.py\n"
115
+ f"- Patch (you): sentinel/sentinel_dev.py\n"
116
+ f"- State + memory: sentinel/state_store.py (SQLite, incl. dev_history)\n"
117
+ f"- Notifications: sentinel/notify.py\n"
118
+ f"- CLI package: cli/ (Node.js, npm package @misterhuydo/sentinel)\n"
119
+ f"- Templates: cli/templates/ (sentinel.properties, workspace-sentinel.properties)\n"
120
+ f"\n"
121
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
122
+ f"INSTRUCTIONS\n"
123
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
124
+ f"1. Explore the relevant source files before making changes.\n"
125
+ f"2. Implement the task. Follow existing patterns:\n"
126
+ f" - Same error handling style (try/except with logger.error)\n"
127
+ f" - Same Slack notification pattern (slack_alert from notify.py)\n"
128
+ f" - Same dataclass + file-watcher pattern (see issue_watcher.py)\n"
129
+ f" - Same async/executor pattern for blocking work (see main.py)\n"
130
+ f"3. Syntax check each modified Python file:\n"
131
+ f" python3 -m py_compile <file>\n"
132
+ f"4. Check modified JS/Node files:\n"
133
+ f" node --check <file>\n"
134
+ f"5. Commit all changes:\n"
135
+ f" git add -A -- sentinel/ cli/ (or be more selective)\n"
136
+ f" git commit -m \"{task.task_type}(dev-agent): <concise summary> [sentinel-dev]\"\n"
137
+ f"6. End your response with a brief summary of what changed (max 10 lines).\n"
138
+ f" Your changes are live immediately — Sentinel loads Python directly from this repo.\n"
139
+ f" A human will review your commits and publish to npm when ready.\n"
140
+ f"\n"
141
+ f"BOUNDARIES (the only things outside your scope):\n"
142
+ f"- Never touch managed application repos (the repos Sentinel monitors — not this repo)\n"
143
+ f"- Do NOT run npm publish — Sentinel handles publishing after your commit\n"
144
+ f"- Never take actions that affect systems outside this server and Sentinel's own repos\n"
145
+ f"\n"
146
+ f"WHEN YOU NEED INFORMATION OR HUMAN DECISION:\n"
147
+ f"- If you have a specific question that only a human admin can answer (e.g. credentials\n"
148
+ f" that must be provided, a business decision, or approval for an irreversible action):\n"
149
+ f" output exactly: ASK_BOSS: <your question>\n"
150
+ f" Sentinel Boss will relay it to the admin team and bring you the answer.\n"
151
+ f" Use this sparingly — try to solve problems autonomously first.\n"
152
+ f"- If the task is genuinely impossible (e.g. requires physical hardware, or contradicts\n"
153
+ f" a hard architectural constraint):\n"
154
+ f" output exactly: SKIP: <reason>\n"
155
+ )
156
+
157
+
158
+ _ASK_BOSS_RETRIES = 2 # how many times Patch may ask Boss per task
159
+
160
+
161
+ def _consult_boss(question: str, task_context: str, cfg: "SentinelConfig") -> str:
162
+ """
163
+ Route a Patch question to the Boss LLM.
164
+ Boss answers from its knowledge of Sentinel, or indicates it needs human input.
165
+ Returns Boss's answer as a string.
166
+ """
167
+ if not cfg.anthropic_api_key:
168
+ return "(Boss unavailable — no API key configured)"
169
+ try:
170
+ import anthropic as _anthropic
171
+ _client = _anthropic.Anthropic(api_key=cfg.anthropic_api_key)
172
+ _resp = _client.messages.create(
173
+ model="claude-opus-4-6",
174
+ max_tokens=600,
175
+ system=(
176
+ "You are Sentinel Boss — the operational orchestrator of the Sentinel DevOps system. "
177
+ "You are answering a question from Patch, your peer AI agent who maintains "
178
+ "Sentinel's source code autonomously. Patch has full authority within Sentinel's "
179
+ "operational scope and only asks you when it truly needs information it cannot find itself.\n\n"
180
+ "Answer concisely and directly. If you know the answer from Sentinel's architecture or "
181
+ "standard practices, give it. If the question requires a human admin decision (e.g. "
182
+ "secret credentials, budget approval, irreversible production changes), reply with:\n"
183
+ "NEEDS_HUMAN: <brief reason>\n\n"
184
+ "Context of the task Patch is working on:\n"
185
+ f"{task_context[:400]}"
186
+ ),
187
+ messages=[{"role": "user", "content": f"Patch asks: {question}"}],
188
+ )
189
+ return _resp.content[0].text.strip() if _resp.content else "(no answer from Boss)"
190
+ except Exception as _e:
191
+ logger.warning("Dev agent: Boss consultation failed: %s", _e)
192
+ return f"(Boss consultation failed: {_e})"
193
+
194
+
195
+
196
+ def _extract_summary(output: str) -> str:
197
+ """Extract the last meaningful paragraph from Claude output (not tool-use lines)."""
198
+ lines = output.splitlines()
199
+ substantive = [
200
+ l for l in lines
201
+ if l.strip() and not re.match(r'^[⏺⎆●✦✓✗]', l.strip())
202
+ ]
203
+ if not substantive:
204
+ return output[-400:].strip()
205
+ return "\n".join(substantive[-12:])[:500]
206
+
207
+
208
+
209
+ def _dev_progress_from_line(line: str) -> str | None:
210
+ """Convert Claude Code tool-use lines to human-readable dev progress messages."""
211
+ _TOOL_RE = re.compile(r'^[⏺⎆●✦]\s*(\w+)\s*\((.{0,120})', re.UNICODE)
212
+ m = _TOOL_RE.match(line.strip())
213
+ if not m:
214
+ return None
215
+ tool, args = m.group(1), m.group(2).rstrip(')')
216
+ if tool == "Bash":
217
+ cmd = args.strip()
218
+ if re.search(r'py_compile|node.*--check', cmd):
219
+ return ":white_check_mark: Syntax checking..."
220
+ if re.search(r'\bgit\b.*\bcommit\b', cmd):
221
+ return ":floppy_disk: Committing changes..."
222
+ if re.search(r'\bgit\b.*\bpush\b', cmd):
223
+ return ":arrow_up: Pushing to remote..."
224
+ if re.search(r'\bnpm\b.*\bpublish\b', cmd):
225
+ return ":rocket: Publishing to npm..."
226
+ if re.search(r'\bgrep\b|\bfind\b|\bls\b|\bcat\b', cmd):
227
+ return ":mag: Exploring codebase..."
228
+ if re.search(r'\bgit\b.*\badd\b|\bgit\b.*\bstatus\b', cmd):
229
+ return ":pencil2: Staging changes..."
230
+ elif tool in ("Edit", "MultiEdit"):
231
+ fname = args.split(",")[0].strip().split("/")[-1]
232
+ return f":pencil2: Editing `{fname}`" if fname else ":pencil2: Editing file..."
233
+ elif tool == "Write":
234
+ fname = args.split(",")[0].strip().split("/")[-1]
235
+ return f":pencil2: Writing `{fname}`" if fname else None
236
+ elif tool == "Read":
237
+ fname = args.split(",")[0].strip().split("/")[-1]
238
+ return f":eyes: Reading `{fname}`" if fname else None
239
+ return None
240
+
241
+
242
+ def run_dev_task(
243
+ task: DevTask,
244
+ cfg: SentinelConfig,
245
+ store,
246
+ on_progress=None,
247
+ ) -> tuple[str, str]:
248
+ """
249
+ Execute a dev task against the Sentinel source repo using Claude Code.
250
+
251
+ Returns (status, detail) where status is one of:
252
+ "done" — task completed and committed
253
+ "published" — task done + npm published + upgrade triggered
254
+ "needs_human" — Claude flagged for human review
255
+ "skip" — Claude explicitly declined
256
+ "error" — runtime failure
257
+
258
+ detail is: new_version string (if published), reason (if skip/needs_human), or "".
259
+ """
260
+ repo_path = cfg.sentinel_dev_repo_path
261
+ if not repo_path or not Path(repo_path).exists():
262
+ logger.error("Dev agent: SENTINEL_DEV_REPO_PATH not set or does not exist: %r", repo_path)
263
+ return "error", f"SENTINEL_DEV_REPO_PATH not configured or missing: {repo_path}"
264
+
265
+ # Fetch similar past outcomes from DB memory to include in prompt
266
+ past_outcomes = []
267
+ if store:
268
+ try:
269
+ past_outcomes = store.get_similar_dev_outcomes(task.body, limit=5)
270
+ except Exception as _e:
271
+ logger.debug("Dev agent: could not fetch past outcomes: %s", _e)
272
+
273
+ prompt = _build_dev_prompt(task, repo_path, past_outcomes=past_outcomes or None)
274
+
275
+ # Set up environment (same pattern as fix_engine.generate_fix)
276
+ base_env = {**os.environ}
277
+ if cfg.anthropic_api_key:
278
+ base_env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
279
+
280
+ # Log path
281
+ claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
282
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
283
+ claude_log_path = claude_logs_dir / f"dev-{task.fingerprint[:8]}-{ts}.log"
284
+
285
+ if on_progress:
286
+ try:
287
+ on_progress(":brain: Dev Claude exploring codebase...")
288
+ except Exception:
289
+ pass
290
+
291
+ def _record(status: str, note: str = "", files: str = "", commit: str = "") -> None:
292
+ if store:
293
+ try:
294
+ store.record_dev_outcome(
295
+ fingerprint=task.fingerprint,
296
+ task_type=task.task_type,
297
+ source=task.source,
298
+ description=task.body,
299
+ status=status,
300
+ outcome_note=note,
301
+ files_changed=files,
302
+ commit_hash=commit,
303
+ )
304
+ except Exception as _e:
305
+ logger.debug("Dev agent: could not record outcome: %s", _e)
306
+
307
+ def _run_claude(current_prompt: str) -> "tuple[str, bool]":
308
+ """Run one Claude attempt with auth fallback. Raises FileNotFoundError if binary missing."""
309
+ if cfg.claude_pro_for_tasks:
310
+ out, tout = _run_claude_attempt(
311
+ cfg.claude_code_bin, current_prompt,
312
+ env=base_env, cwd=repo_path,
313
+ claude_log_path=claude_log_path,
314
+ on_progress=lambda line: _fire_progress(line, on_progress),
315
+ )
316
+ if _is_auth_error(out):
317
+ logger.warning("Dev agent: OAuth auth failed, trying API key")
318
+ out, tout = _run_claude_attempt(
319
+ cfg.claude_code_bin, current_prompt,
320
+ env={**base_env, "ANTHROPIC_API_KEY": cfg.anthropic_api_key or ""},
321
+ cwd=repo_path,
322
+ claude_log_path=claude_log_path,
323
+ on_progress=lambda line: _fire_progress(line, on_progress),
324
+ )
325
+ return out, tout
326
+ return _run_claude_attempt(
327
+ cfg.claude_code_bin, current_prompt,
328
+ env=base_env, cwd=repo_path,
329
+ claude_log_path=claude_log_path,
330
+ on_progress=lambda line: _fire_progress(line, on_progress),
331
+ )
332
+
333
+ # ── Run Dev Claude, with Boss consultation loop for ASK_BOSS: ────────────
334
+ current_prompt = prompt
335
+ boss_exchanges: list[str] = [] # accumulated Q&A to append to prompt on retry
336
+
337
+ for attempt in range(_ASK_BOSS_RETRIES + 1):
338
+ try:
339
+ output, timed_out = _run_claude(current_prompt)
340
+ except FileNotFoundError:
341
+ logger.error("Dev agent: claude binary not found: %s", cfg.claude_code_bin)
342
+ return "error", f"Claude CLI not found at {cfg.claude_code_bin}"
343
+
344
+ if timed_out:
345
+ logger.error("Dev agent: Claude timed out for task %s", task.fingerprint[:8])
346
+ return "error", "Patch timed out after 15 minutes."
347
+
348
+ stripped = output.strip()
349
+
350
+ # Dev Claude has a question → consult Boss → retry with answer
351
+ if stripped.upper().startswith("ASK_BOSS:"):
352
+ question = stripped[len("ASK_BOSS:"):].strip()
353
+ logger.info(
354
+ "Dev agent: ASK_BOSS (attempt %d/%d): %s",
355
+ attempt + 1, _ASK_BOSS_RETRIES + 1, question[:200],
356
+ )
357
+ if on_progress:
358
+ try:
359
+ on_progress(f":speech_balloon: Asking Boss: _{question[:120]}_")
360
+ except Exception:
361
+ pass
362
+ boss_answer = _consult_boss(question, task.body, cfg)
363
+ logger.info("Dev agent: Boss answered: %s", boss_answer[:200])
364
+
365
+ # If Boss itself needs human input, surface it
366
+ if boss_answer.upper().startswith("NEEDS_HUMAN:"):
367
+ human_reason = boss_answer[len("NEEDS_HUMAN:"):].strip()
368
+ _record("needs_human", note=human_reason[:400])
369
+ return "needs_human", human_reason
370
+
371
+ # Append Q&A to prompt and retry
372
+ exchange = (
373
+ f"\nBoss answered your question:\n"
374
+ f"Q: {question}\n"
375
+ f"A: {boss_answer}\n"
376
+ f"Now continue the task with this information.\n"
377
+ )
378
+ boss_exchanges.append(exchange)
379
+ current_prompt = prompt + "\n\n━━ BOSS CONSULTATION HISTORY ━━" + "".join(boss_exchanges)
380
+ if on_progress:
381
+ try:
382
+ on_progress(":arrows_counterclockwise: Dev Claude resuming with Boss's answer...")
383
+ except Exception:
384
+ pass
385
+ continue # retry with enriched prompt
386
+
387
+ # Not an ASK_BOSS — process the final output
388
+ break
389
+ else:
390
+ # Exhausted retries — treat as needs_human (Boss couldn't unblock Dev Claude)
391
+ _record("needs_human", note="Exhausted Boss consultations without completing task")
392
+ return "needs_human", "Patch could not complete the task after consulting Boss. Human review needed."
393
+
394
+ if stripped.upper().startswith("SKIP:"):
395
+ reason = stripped[5:].strip()
396
+ logger.info("Dev agent: skipped task %s: %s", task.fingerprint[:8], reason[:200])
397
+ _record("skip", note=reason[:400])
398
+ return "skip", reason
399
+
400
+ summary = _extract_summary(output)
401
+ logger.info("Dev agent: task %s completed", task.fingerprint[:8])
402
+
403
+ # Extract files changed from git output in Claude's response (best-effort)
404
+ _files_re = re.findall(r'\bsentinel/\S+\.py\b|\bcli/\S+\.(?:js|json)\b', output)
405
+ files_str = ", ".join(dict.fromkeys(_files_re))[:300]
406
+
407
+ _record("done", note=summary, files=files_str)
408
+ return "done", ""
409
+
410
+
411
+ def _fire_progress(line: str, on_progress) -> None:
412
+ """Translate a raw Claude output line to progress and fire the callback."""
413
+ if on_progress:
414
+ msg = _dev_progress_from_line(line)
415
+ if msg:
416
+ try:
417
+ on_progress(msg)
418
+ except Exception:
419
+ pass
420
+
421
+
422
+ def drop_escalation(project_dir: Path, description: str, source: str = "fix_engine/BOSS_ESCALATE",
423
+ source_fingerprint: str = "", submitter_user_id: str = "") -> Path:
424
+ """
425
+ Create a dev task file for an escalation from a child Claude instance or Boss.
426
+ Returns the path to the created file.
427
+ """
428
+ dev_tasks_dir = project_dir / "dev-tasks"
429
+ dev_tasks_dir.mkdir(exist_ok=True)
430
+ ts = int(time.time())
431
+ fp_part = source_fingerprint[:8] if source_fingerprint else "esc"
432
+ fname = f"bot-{fp_part}-{ts}.txt"
433
+ fpath = dev_tasks_dir / fname
434
+
435
+ lines = [
436
+ "TYPE: fix",
437
+ f"SOURCE: {source}",
438
+ ]
439
+ if source_fingerprint:
440
+ lines.append(f"SOURCE_FINGERPRINT: {source_fingerprint}")
441
+ if submitter_user_id:
442
+ lines.append(f"SUBMITTED_BY: ({submitter_user_id})")
443
+ lines.append(f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}")
444
+ lines.append("")
445
+ lines.append(description)
446
+ fpath.write_text("\n".join(lines), encoding="utf-8")
447
+ logger.info("Dev escalation dropped: %s", fname)
448
+ return fpath
@@ -4,6 +4,7 @@ state_store.py — SQLite-backed persistence for errors, fixes, and reports.
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
+ import re
7
8
  import sqlite3
8
9
  import logging
9
10
  from contextlib import contextmanager
@@ -556,3 +557,123 @@ class StateStore:
556
557
  "SELECT * FROM errors ORDER BY last_seen DESC"
557
558
  ).fetchall()
558
559
  return [dict(r) for r in rows]
560
+
561
+ # ── Knowledge cache (ask_codebase results) ────────────────────────────────
562
+
563
+ def get_knowledge(self, repo_name: str, question: str) -> "str | None":
564
+ """Return cached answer for a codebase question, or None if expired/missing."""
565
+ with self._conn() as conn:
566
+ conn.execute(
567
+ "CREATE TABLE IF NOT EXISTS knowledge_cache ("
568
+ " repo_name TEXT NOT NULL,"
569
+ " question TEXT NOT NULL,"
570
+ " answer TEXT NOT NULL,"
571
+ " expires_at TEXT NOT NULL,"
572
+ " PRIMARY KEY (repo_name, question)"
573
+ ")"
574
+ )
575
+ row = conn.execute(
576
+ "SELECT answer FROM knowledge_cache "
577
+ "WHERE repo_name=? AND question=? AND expires_at > datetime('now')",
578
+ (repo_name, question),
579
+ ).fetchone()
580
+ return row["answer"] if row else None
581
+
582
+ def save_knowledge(self, repo_name: str, question: str, answer: str, ttl_hours: int = 24) -> None:
583
+ """Cache a codebase question answer with a TTL."""
584
+ with self._conn() as conn:
585
+ conn.execute(
586
+ "CREATE TABLE IF NOT EXISTS knowledge_cache ("
587
+ " repo_name TEXT NOT NULL,"
588
+ " question TEXT NOT NULL,"
589
+ " answer TEXT NOT NULL,"
590
+ " expires_at TEXT NOT NULL,"
591
+ " PRIMARY KEY (repo_name, question)"
592
+ ")"
593
+ )
594
+ conn.execute(
595
+ "INSERT OR REPLACE INTO knowledge_cache (repo_name, question, answer, expires_at) "
596
+ "VALUES (?, ?, ?, datetime('now', ? || ' hours'))",
597
+ (repo_name, question, answer, str(ttl_hours)),
598
+ )
599
+
600
+ # ── Dev Claude memory (self-repair history + learning) ────────────────────
601
+
602
+ def _ensure_dev_history(self, conn) -> None:
603
+ conn.execute(
604
+ "CREATE TABLE IF NOT EXISTS dev_history ("
605
+ " fingerprint TEXT PRIMARY KEY,"
606
+ " task_type TEXT NOT NULL,"
607
+ " source TEXT NOT NULL,"
608
+ " description TEXT NOT NULL,"
609
+ " status TEXT NOT NULL,"
610
+ " outcome_note TEXT NOT NULL DEFAULT '',"
611
+ " files_changed TEXT NOT NULL DEFAULT '',"
612
+ " commit_hash TEXT NOT NULL DEFAULT '',"
613
+ " recorded_at TEXT NOT NULL"
614
+ ")"
615
+ )
616
+
617
+ def record_dev_outcome(
618
+ self,
619
+ fingerprint: str,
620
+ task_type: str,
621
+ source: str,
622
+ description: str,
623
+ status: str,
624
+ outcome_note: str = "",
625
+ files_changed: str = "",
626
+ commit_hash: str = "",
627
+ ) -> None:
628
+ """Persist the outcome of a Dev Claude task for future learning."""
629
+ with self._conn() as conn:
630
+ self._ensure_dev_history(conn)
631
+ conn.execute(
632
+ "INSERT OR REPLACE INTO dev_history "
633
+ "(fingerprint, task_type, source, description, status, "
634
+ " outcome_note, files_changed, commit_hash, recorded_at) "
635
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
636
+ (fingerprint, task_type, source, description[:500], status,
637
+ outcome_note[:500], files_changed[:300], commit_hash),
638
+ )
639
+
640
+ def get_dev_history(self, limit: int = 20) -> list[dict]:
641
+ """Return recent Dev Claude task outcomes, newest first."""
642
+ with self._conn() as conn:
643
+ self._ensure_dev_history(conn)
644
+ rows = conn.execute(
645
+ "SELECT fingerprint, task_type, source, description, status, "
646
+ " outcome_note, files_changed, commit_hash, recorded_at "
647
+ "FROM dev_history ORDER BY recorded_at DESC LIMIT ?",
648
+ (limit,),
649
+ ).fetchall()
650
+ return [dict(r) for r in rows]
651
+
652
+ def get_similar_dev_outcomes(self, description: str, limit: int = 5) -> list[dict]:
653
+ """
654
+ Return past Dev Claude outcomes whose description overlaps with the given one.
655
+ Used to give Dev Claude context about similar past fixes before it starts work.
656
+ """
657
+ _stop = {"error", "sentinel", "false", "true", "none", "that", "this",
658
+ "with", "from", "have", "been", "when", "where", "they", "their"}
659
+ words = [
660
+ w.lower() for w in re.findall(r'\b\w{5,}\b', description)
661
+ if w.lower() not in _stop
662
+ ]
663
+ if not words:
664
+ return []
665
+ with self._conn() as conn:
666
+ self._ensure_dev_history(conn)
667
+ clauses = " OR ".join(["description LIKE ?" for _ in words[:6]])
668
+ params = [f"%{w}%" for w in words[:6]] + [limit * 3]
669
+ rows = conn.execute(
670
+ f"SELECT fingerprint, task_type, status, outcome_note, "
671
+ f" description, recorded_at "
672
+ f"FROM dev_history WHERE ({clauses}) "
673
+ f"ORDER BY recorded_at DESC LIMIT ?",
674
+ params,
675
+ ).fetchall()
676
+ def _score(r):
677
+ text = (r["description"] + " " + r["outcome_note"]).lower()
678
+ return sum(1 for w in words if w in text)
679
+ return sorted([dict(r) for r in rows], key=_score, reverse=True)[:limit]
@@ -1,54 +1,43 @@
1
1
  # log-configs/_example.properties
2
2
  #
3
3
  # One file per log stream (SSH server or Cloudflare worker).
4
- # The filename stem must match the corresponding repo-configs/<stem>.properties
5
- # so Sentinel knows which repository to fix errors from this log source.
4
+ # Copy this to e.g. "MyService.properties" — the filename stem must match the
5
+ # corresponding repo-configs/<stem>.properties. Override with TARGET_REPO if they differ.
6
6
  #
7
- # Copy this file to e.g. "elprint-salescore.properties" and fill in the values.
8
- #
9
- # ── Source type ───────────────────────────────────────────────────────────────
10
7
 
11
8
  # ssh | cloudflare
12
9
  SOURCE_TYPE=ssh
13
10
 
14
- # ── SSH source (SOURCE_TYPE=ssh) ──────────────────────────────────────────────
11
+ # ── SSH source ────────────────────────────────────────────────────────────────
12
+ # Sentinel SSHes in and streams the remote log with grep + tail on each poll.
15
13
 
16
- # SSH private key (.pem). Relative path is resolved from the config dir, then ~/.ssh/
17
- KEY=prod.pem
14
+ # SSH private key (.pem) relative path resolved from this config dir, then ~/.ssh/
15
+ KEY=my-service.pem
18
16
 
19
- # Comma-separated list of hostnames or user@host entries.
20
- # Hosts without a user@ prefix default to ec2-user@<host>
21
- HOSTS=ec2-xx-xx-xx-xx.eu-north-1.compute.amazonaws.com, ec2-xx-xx-xx-xx.eu-north-1.compute.amazonaws.com
17
+ # Comma-separated hosts. Hosts without user@ prefix default to ec2-user@<host>
18
+ HOSTS=ec2-xx-xx-xx-xx.eu-north-1.compute.amazonaws.com
22
19
 
23
- # Comma-separated list of log file paths relative to /home/<REMOTE_SERVICE_USER>/
24
- LOGS=logs/AppService.log, logs/alarm.log, logs/warning.log
20
+ # Comma-separated log paths relative to /home/<REMOTE_SERVICE_USER>/ on each host
21
+ LOGS=logs/AppService.log, logs/alarm.log
25
22
 
26
- # The Linux user owning the log files on the remote host (used to build the path)
23
+ # Linux user owning the log files on the remote host (defaults to filename stem)
27
24
  REMOTE_SERVICE_USER=MyServiceUser
28
25
 
29
- # Lines to fetch (tail -n N). Takes precedence over HEAD if both set.
30
- TAIL=500
31
-
32
- # Lines to fetch from the top instead (head -n N). Only used if TAIL is not set.
33
- # HEAD=100
34
-
35
- # Keep only lines matching this regex (grep -E)
36
- GREP_FILTER=WARN|ERROR
26
+ # Keep only lines matching this regex (default: WARN|ERROR|FATAL|Exception|Error)
27
+ GREP_FILTER=WARN|ERROR|FATAL
37
28
 
38
- # Drop lines matching this regex (grep -iv)
39
- GREP_EXCLUDE=SSLTool|CommandValidate|hystrix
29
+ # Drop lines matching this regex (case-insensitive, optional)
30
+ # GREP_EXCLUDE=HealthCheck|actuator
40
31
 
41
- # ── Routing ───────────────────────────────────────────────────────────────────
32
+ # How many lines to read from the end of each log file (default: 1000)
33
+ # TAIL=500
42
34
 
43
- # Which repo-config to route errors from this log source to.
44
- # The filename stem is the default match (e.g. "MyService.properties" → "MyService" repo-config).
45
- # Set TARGET_REPO to override with the exact repo-config filename stem.
35
+ # Override repo routing exact filename stem of the target repo-config
46
36
  # TARGET_REPO=MyService
47
37
 
48
- # ── Cloudflare source (SOURCE_TYPE=cloudflare) ────────────────────────────────
38
+ # ── Cloudflare source ─────────────────────────────────────────────────────────
39
+ # Sentinel fetches logs via HTTP GET with cursor pagination — no SSH needed.
49
40
 
50
- # Full URL of the Cloudflare Worker log endpoint
51
41
  # CF_URL=https://logs.<worker>.workers.dev/<service>
52
-
53
- # Bearer token for the Cloudflare Worker
54
42
  # CF_TOKEN=<bearer-token>
43
+ # TARGET_REPO=MyService