@misterhuydo/sentinel 1.4.89 → 1.4.90

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,490 @@
1
+ """
2
+ sentinel_dev.py — Autonomous Developer Claude agent for Sentinel self-improvement.
3
+
4
+ Dev Claude 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
+ Invocation sources:
9
+ - Boss (dev_task tool) → slack-<uuid>.txt in dev-tasks/
10
+ - Fix engine (BOSS_ESCALATE output) → bot-<fp>-<ts>.txt in dev-tasks/
11
+ - Admin (manual file drop) → any .txt in dev-tasks/
12
+
13
+ After a successful task:
14
+ - Commits changes to the Sentinel source repo
15
+ - If SENTINEL_DEV_AUTO_PUBLISH=true: bumps patch version + npm publish + sentinel upgrade
16
+ - Posts Slack notification with summary
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import time
25
+
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+
29
+ from .config_loader import SentinelConfig
30
+ from .dev_watcher import DevTask
31
+ from .fix_engine import _claude_cmd, _run_claude_attempt, _write_claude_log, _is_auth_error
32
+ from .notify import slack_alert
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Dev tasks are bigger than fix tasks — allow 15 minutes
37
+ _DEV_TIMEOUT = 900
38
+
39
+ _BOSS_ESCALATE_PREFIX = "BOSS_ESCALATE:"
40
+
41
+
42
+ def _build_dev_prompt(task: DevTask, repo_path: str, past_outcomes: list | None = None) -> str:
43
+ submitted = f"Submitted by: <@{task.submitter_user_id}>" if task.submitter_user_id else ""
44
+ escalation_ctx = ""
45
+ if task.source_fingerprint:
46
+ escalation_ctx = (
47
+ f"\nThis task was escalated by the fix engine (error fingerprint: "
48
+ f"{task.source_fingerprint}) because it required a change to Sentinel itself.\n"
49
+ )
50
+
51
+ memory_ctx = ""
52
+ if past_outcomes:
53
+ lines = ["━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
54
+ "MEMORY — SIMILAR PAST TASKS (learn from these)",
55
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
56
+ for o in past_outcomes:
57
+ lines.append(
58
+ f"[{o['recorded_at'][:10]}] {o['status'].upper()} — {o['task_type']}: "
59
+ f"{o['description'][:120]}"
60
+ )
61
+ if o.get("outcome_note"):
62
+ lines.append(f" Outcome: {o['outcome_note'][:200]}")
63
+ if o.get("files_changed"):
64
+ lines.append(f" Files: {o['files_changed']}")
65
+ lines.append(
66
+ "Use these as reference: repeat what worked, avoid what failed or was skipped."
67
+ )
68
+ memory_ctx = "\n".join(lines) + "\n\n"
69
+
70
+ return (
71
+ f"You are Sentinel Developer — a fully autonomous AI agent responsible for the\n"
72
+ f"resiliency, stability, and continuous improvement of the Sentinel DevOps system.\n"
73
+ f"Sentinel source repository: {repo_path}\n"
74
+ f"{submitted}\n"
75
+ f"{escalation_ctx}\n"
76
+ f"{memory_ctx}"
77
+ f"TASK TYPE: {task.task_type}\n"
78
+ f"TASK:\n{task.body}\n\n"
79
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
80
+ f"YOUR ROLE AND AUTHORITY\n"
81
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
82
+ f"You have FULL AUTONOMY within Sentinel's operational scope. This means:\n"
83
+ f"- Modify any source file in this repo (Python, JS, configs, templates)\n"
84
+ f"- Edit config files (sentinel.properties, log-configs/, repo-configs/)\n"
85
+ f"- Install OS packages or CLI tools needed to make Sentinel work\n"
86
+ f" (e.g. git, curl, jq, common CI/CD tools — use dnf/yum/apt as appropriate)\n"
87
+ f"- Run scripts, restart services, or apply system-level fixes within scope\n"
88
+ f"- Commit and push changes to the Sentinel source repository\n"
89
+ f"You do NOT need permission for any of the above. Act, don't ask.\n"
90
+ f"\n"
91
+ f"You work WITH Sentinel Boss, not under it. The relationship:\n"
92
+ f" Humans → Boss (Boss obeys humans)\n"
93
+ f" Boss ↔ Dev Claude (peer agents — you can ask Boss, Boss can task you)\n"
94
+ f" Dev Claude → Sentinel (full autonomy)\n"
95
+ f"\n"
96
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
97
+ f"SENTINEL CODEBASE OVERVIEW\n"
98
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
99
+ f"- Entry point: sentinel/main.py (asyncio poll loop + dev poll loop)\n"
100
+ f"- Config: sentinel/config_loader.py (SentinelConfig, LogSourceConfig, RepoConfig)\n"
101
+ f"- Slack bot + tools: sentinel/sentinel_boss.py\n"
102
+ f"- Fix engine: sentinel/fix_engine.py (Claude Code subprocess runner)\n"
103
+ f"- Git ops: sentinel/git_manager.py\n"
104
+ f"- Log fetching: sentinel/log_fetcher.py, sentinel/log_syncer.py\n"
105
+ f"- Log parsing: sentinel/log_parser.py\n"
106
+ f"- Issue queue: sentinel/issue_watcher.py\n"
107
+ f"- Dev task queue + self-repair: sentinel/dev_watcher.py\n"
108
+ f"- Dev Claude agent: sentinel/sentinel_dev.py\n"
109
+ f"- State + memory: sentinel/state_store.py (SQLite, incl. dev_history)\n"
110
+ f"- Notifications: sentinel/notify.py\n"
111
+ f"- CLI package: cli/ (Node.js, npm package @misterhuydo/sentinel)\n"
112
+ f"- Templates: cli/templates/ (sentinel.properties, workspace-sentinel.properties)\n"
113
+ f"\n"
114
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
115
+ f"INSTRUCTIONS\n"
116
+ f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
117
+ f"1. Explore the relevant source files before making changes.\n"
118
+ f"2. Implement the task. Follow existing patterns:\n"
119
+ f" - Same error handling style (try/except with logger.error)\n"
120
+ f" - Same Slack notification pattern (slack_alert from notify.py)\n"
121
+ f" - Same dataclass + file-watcher pattern (see issue_watcher.py)\n"
122
+ f" - Same async/executor pattern for blocking work (see main.py)\n"
123
+ f"3. Syntax check each modified Python file:\n"
124
+ f" python3 -m py_compile <file>\n"
125
+ f"4. Check modified JS/Node files:\n"
126
+ f" node --check <file>\n"
127
+ f"5. Commit all changes:\n"
128
+ f" git add -A -- sentinel/ cli/ (or be more selective)\n"
129
+ f" git commit -m \"{task.task_type}(dev-agent): <concise summary> [sentinel-dev]\"\n"
130
+ f"6. If this is a meaningful new feature or fix (not a trivial chore), output on its own line:\n"
131
+ f" VERSION_BUMPED: <new_version>\n"
132
+ f" (The version is in cli/package.json — bump the patch number)\n"
133
+ f"7. End your response with a brief summary of what changed (max 10 lines).\n"
134
+ f"\n"
135
+ f"BOUNDARIES (the only things outside your scope):\n"
136
+ f"- Never touch managed application repos (the repos Sentinel monitors — not this repo)\n"
137
+ f"- Do NOT run npm publish — Sentinel handles publishing after your commit\n"
138
+ f"- Never take actions that affect systems outside this server and Sentinel's own repos\n"
139
+ f"\n"
140
+ f"WHEN YOU NEED INFORMATION OR HUMAN DECISION:\n"
141
+ f"- If you have a specific question that only a human admin can answer (e.g. credentials\n"
142
+ f" that must be provided, a business decision, or approval for an irreversible action):\n"
143
+ f" output exactly: ASK_BOSS: <your question>\n"
144
+ f" Sentinel Boss will relay it to the admin team and bring you the answer.\n"
145
+ f" Use this sparingly — try to solve problems autonomously first.\n"
146
+ f"- If the task is genuinely impossible (e.g. requires physical hardware, or contradicts\n"
147
+ f" a hard architectural constraint):\n"
148
+ f" output exactly: SKIP: <reason>\n"
149
+ )
150
+
151
+
152
+ _ASK_BOSS_RETRIES = 2 # how many times Dev Claude may ask Boss per task
153
+
154
+
155
+ def _consult_boss(question: str, task_context: str, cfg: "SentinelConfig") -> str:
156
+ """
157
+ Route a Dev Claude question to the Boss LLM.
158
+ Boss answers from its knowledge of Sentinel, or indicates it needs human input.
159
+ Returns Boss's answer as a string.
160
+ """
161
+ if not cfg.anthropic_api_key:
162
+ return "(Boss unavailable — no API key configured)"
163
+ try:
164
+ import anthropic as _anthropic
165
+ _client = _anthropic.Anthropic(api_key=cfg.anthropic_api_key)
166
+ _resp = _client.messages.create(
167
+ model="claude-opus-4-6",
168
+ max_tokens=600,
169
+ system=(
170
+ "You are Sentinel Boss — the operational orchestrator of the Sentinel DevOps system. "
171
+ "You are answering a question from Dev Claude, your peer AI agent who maintains "
172
+ "Sentinel's source code autonomously. Dev Claude has full authority within Sentinel's "
173
+ "operational scope and only asks you when it truly needs information it cannot find itself.\n\n"
174
+ "Answer concisely and directly. If you know the answer from Sentinel's architecture or "
175
+ "standard practices, give it. If the question requires a human admin decision (e.g. "
176
+ "secret credentials, budget approval, irreversible production changes), reply with:\n"
177
+ "NEEDS_HUMAN: <brief reason>\n\n"
178
+ "Context of the task Dev Claude is working on:\n"
179
+ f"{task_context[:400]}"
180
+ ),
181
+ messages=[{"role": "user", "content": f"Dev Claude asks: {question}"}],
182
+ )
183
+ return _resp.content[0].text.strip() if _resp.content else "(no answer from Boss)"
184
+ except Exception as _e:
185
+ logger.warning("Dev agent: Boss consultation failed: %s", _e)
186
+ return f"(Boss consultation failed: {_e})"
187
+
188
+
189
+ def _extract_version_bumped(output: str) -> str | None:
190
+ """Parse VERSION_BUMPED: <version> from Claude output."""
191
+ m = re.search(r'^VERSION_BUMPED:\s*(\S+)', output, re.MULTILINE | re.IGNORECASE)
192
+ return m.group(1) if m else None
193
+
194
+
195
+ def _extract_summary(output: str) -> str:
196
+ """Extract the last meaningful paragraph from Claude output (not tool-use lines)."""
197
+ lines = output.splitlines()
198
+ substantive = [
199
+ l for l in lines
200
+ if l.strip() and not re.match(r'^[⏺⎆●✦✓✗]', l.strip())
201
+ and not l.strip().startswith("VERSION_BUMPED")
202
+ ]
203
+ if not substantive:
204
+ return output[-400:].strip()
205
+ return "\n".join(substantive[-12:])[:500]
206
+
207
+
208
+ def _run_npm_publish(repo_path: str, env: dict, on_progress=None) -> bool:
209
+ """Run npm publish from cli/ directory. Returns True on success."""
210
+ cli_dir = Path(repo_path) / "cli"
211
+ if not cli_dir.exists():
212
+ logger.warning("Dev agent: cli/ not found at %s", cli_dir)
213
+ return False
214
+ if on_progress:
215
+ try:
216
+ on_progress(":rocket: Publishing to npm...")
217
+ except Exception:
218
+ pass
219
+ try:
220
+ r = subprocess.run(
221
+ ["npm", "publish", "--access", "public"],
222
+ cwd=str(cli_dir),
223
+ capture_output=True, text=True, timeout=120, env=env,
224
+ )
225
+ if r.returncode == 0:
226
+ logger.info("Dev agent: npm publish succeeded")
227
+ return True
228
+ logger.error("Dev agent: npm publish failed (rc=%d): %s", r.returncode, r.stderr[:300])
229
+ return False
230
+ except Exception as e:
231
+ logger.error("Dev agent: npm publish error: %s", e)
232
+ return False
233
+
234
+
235
+
236
+ def _dev_progress_from_line(line: str) -> str | None:
237
+ """Convert Claude Code tool-use lines to human-readable dev progress messages."""
238
+ _TOOL_RE = re.compile(r'^[⏺⎆●✦]\s*(\w+)\s*\((.{0,120})', re.UNICODE)
239
+ m = _TOOL_RE.match(line.strip())
240
+ if not m:
241
+ return None
242
+ tool, args = m.group(1), m.group(2).rstrip(')')
243
+ if tool == "Bash":
244
+ cmd = args.strip()
245
+ if re.search(r'py_compile|node.*--check', cmd):
246
+ return ":white_check_mark: Syntax checking..."
247
+ if re.search(r'\bgit\b.*\bcommit\b', cmd):
248
+ return ":floppy_disk: Committing changes..."
249
+ if re.search(r'\bgit\b.*\bpush\b', cmd):
250
+ return ":arrow_up: Pushing to remote..."
251
+ if re.search(r'\bnpm\b.*\bpublish\b', cmd):
252
+ return ":rocket: Publishing to npm..."
253
+ if re.search(r'\bgrep\b|\bfind\b|\bls\b|\bcat\b', cmd):
254
+ return ":mag: Exploring codebase..."
255
+ if re.search(r'\bgit\b.*\badd\b|\bgit\b.*\bstatus\b', cmd):
256
+ return ":pencil2: Staging changes..."
257
+ elif tool in ("Edit", "MultiEdit"):
258
+ fname = args.split(",")[0].strip().split("/")[-1]
259
+ return f":pencil2: Editing `{fname}`" if fname else ":pencil2: Editing file..."
260
+ elif tool == "Write":
261
+ fname = args.split(",")[0].strip().split("/")[-1]
262
+ return f":pencil2: Writing `{fname}`" if fname else None
263
+ elif tool == "Read":
264
+ fname = args.split(",")[0].strip().split("/")[-1]
265
+ return f":eyes: Reading `{fname}`" if fname else None
266
+ return None
267
+
268
+
269
+ def run_dev_task(
270
+ task: DevTask,
271
+ cfg: SentinelConfig,
272
+ store,
273
+ on_progress=None,
274
+ ) -> tuple[str, str]:
275
+ """
276
+ Execute a dev task against the Sentinel source repo using Claude Code.
277
+
278
+ Returns (status, detail) where status is one of:
279
+ "done" — task completed and committed
280
+ "published" — task done + npm published + upgrade triggered
281
+ "needs_human" — Claude flagged for human review
282
+ "skip" — Claude explicitly declined
283
+ "error" — runtime failure
284
+
285
+ detail is: new_version string (if published), reason (if skip/needs_human), or "".
286
+ """
287
+ repo_path = cfg.sentinel_dev_repo_path
288
+ if not repo_path or not Path(repo_path).exists():
289
+ logger.error("Dev agent: SENTINEL_DEV_REPO_PATH not set or does not exist: %r", repo_path)
290
+ return "error", f"SENTINEL_DEV_REPO_PATH not configured or missing: {repo_path}"
291
+
292
+ # Fetch similar past outcomes from DB memory to include in prompt
293
+ past_outcomes = []
294
+ if store:
295
+ try:
296
+ past_outcomes = store.get_similar_dev_outcomes(task.body, limit=5)
297
+ except Exception as _e:
298
+ logger.debug("Dev agent: could not fetch past outcomes: %s", _e)
299
+
300
+ prompt = _build_dev_prompt(task, repo_path, past_outcomes=past_outcomes or None)
301
+
302
+ # Set up environment (same pattern as fix_engine.generate_fix)
303
+ base_env = {**os.environ}
304
+ if cfg.anthropic_api_key:
305
+ base_env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
306
+
307
+ # Log path
308
+ claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
309
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
310
+ claude_log_path = claude_logs_dir / f"dev-{task.fingerprint[:8]}-{ts}.log"
311
+
312
+ if on_progress:
313
+ try:
314
+ on_progress(":brain: Dev Claude exploring codebase...")
315
+ except Exception:
316
+ pass
317
+
318
+ def _record(status: str, note: str = "", files: str = "", commit: str = "") -> None:
319
+ if store:
320
+ try:
321
+ store.record_dev_outcome(
322
+ fingerprint=task.fingerprint,
323
+ task_type=task.task_type,
324
+ source=task.source,
325
+ description=task.body,
326
+ status=status,
327
+ outcome_note=note,
328
+ files_changed=files,
329
+ commit_hash=commit,
330
+ )
331
+ except Exception as _e:
332
+ logger.debug("Dev agent: could not record outcome: %s", _e)
333
+
334
+ def _run_claude(current_prompt: str) -> "tuple[str, bool]":
335
+ """Run one Claude attempt with auth fallback. Raises FileNotFoundError if binary missing."""
336
+ if cfg.claude_pro_for_tasks:
337
+ out, tout = _run_claude_attempt(
338
+ cfg.claude_code_bin, current_prompt,
339
+ env=base_env, cwd=repo_path,
340
+ claude_log_path=claude_log_path,
341
+ on_progress=lambda line: _fire_progress(line, on_progress),
342
+ )
343
+ if _is_auth_error(out):
344
+ logger.warning("Dev agent: OAuth auth failed, trying API key")
345
+ out, tout = _run_claude_attempt(
346
+ cfg.claude_code_bin, current_prompt,
347
+ env={**base_env, "ANTHROPIC_API_KEY": cfg.anthropic_api_key or ""},
348
+ cwd=repo_path,
349
+ claude_log_path=claude_log_path,
350
+ on_progress=lambda line: _fire_progress(line, on_progress),
351
+ )
352
+ return out, tout
353
+ return _run_claude_attempt(
354
+ cfg.claude_code_bin, current_prompt,
355
+ env=base_env, cwd=repo_path,
356
+ claude_log_path=claude_log_path,
357
+ on_progress=lambda line: _fire_progress(line, on_progress),
358
+ )
359
+
360
+ # ── Run Dev Claude, with Boss consultation loop for ASK_BOSS: ────────────
361
+ current_prompt = prompt
362
+ boss_exchanges: list[str] = [] # accumulated Q&A to append to prompt on retry
363
+
364
+ for attempt in range(_ASK_BOSS_RETRIES + 1):
365
+ try:
366
+ output, timed_out = _run_claude(current_prompt)
367
+ except FileNotFoundError:
368
+ logger.error("Dev agent: claude binary not found: %s", cfg.claude_code_bin)
369
+ return "error", f"Claude CLI not found at {cfg.claude_code_bin}"
370
+
371
+ if timed_out:
372
+ logger.error("Dev agent: Claude timed out for task %s", task.fingerprint[:8])
373
+ return "error", "Dev Claude timed out after 15 minutes."
374
+
375
+ stripped = output.strip()
376
+
377
+ # Dev Claude has a question → consult Boss → retry with answer
378
+ if stripped.upper().startswith("ASK_BOSS:"):
379
+ question = stripped[len("ASK_BOSS:"):].strip()
380
+ logger.info(
381
+ "Dev agent: ASK_BOSS (attempt %d/%d): %s",
382
+ attempt + 1, _ASK_BOSS_RETRIES + 1, question[:200],
383
+ )
384
+ if on_progress:
385
+ try:
386
+ on_progress(f":speech_balloon: Asking Boss: _{question[:120]}_")
387
+ except Exception:
388
+ pass
389
+ boss_answer = _consult_boss(question, task.body, cfg)
390
+ logger.info("Dev agent: Boss answered: %s", boss_answer[:200])
391
+
392
+ # If Boss itself needs human input, surface it
393
+ if boss_answer.upper().startswith("NEEDS_HUMAN:"):
394
+ human_reason = boss_answer[len("NEEDS_HUMAN:"):].strip()
395
+ _record("needs_human", note=human_reason[:400])
396
+ return "needs_human", human_reason
397
+
398
+ # Append Q&A to prompt and retry
399
+ exchange = (
400
+ f"\nBoss answered your question:\n"
401
+ f"Q: {question}\n"
402
+ f"A: {boss_answer}\n"
403
+ f"Now continue the task with this information.\n"
404
+ )
405
+ boss_exchanges.append(exchange)
406
+ current_prompt = prompt + "\n\n━━ BOSS CONSULTATION HISTORY ━━" + "".join(boss_exchanges)
407
+ if on_progress:
408
+ try:
409
+ on_progress(":arrows_counterclockwise: Dev Claude resuming with Boss's answer...")
410
+ except Exception:
411
+ pass
412
+ continue # retry with enriched prompt
413
+
414
+ # Not an ASK_BOSS — process the final output
415
+ break
416
+ else:
417
+ # Exhausted retries — treat as needs_human (Boss couldn't unblock Dev Claude)
418
+ _record("needs_human", note="Exhausted Boss consultations without completing task")
419
+ return "needs_human", "Dev Claude could not complete the task after consulting Boss. Human review needed."
420
+
421
+ if stripped.upper().startswith("SKIP:"):
422
+ reason = stripped[5:].strip()
423
+ logger.info("Dev agent: skipped task %s: %s", task.fingerprint[:8], reason[:200])
424
+ _record("skip", note=reason[:400])
425
+ return "skip", reason
426
+
427
+ # Parse optional VERSION_BUMPED signal and summary
428
+ new_version = _extract_version_bumped(output)
429
+ summary = _extract_summary(output)
430
+
431
+ logger.info(
432
+ "Dev agent: task %s completed (version_bumped=%s)",
433
+ task.fingerprint[:8], new_version or "no",
434
+ )
435
+
436
+ # Extract files changed from git output in Claude's response (best-effort)
437
+ _files_re = re.findall(r'\bsentinel/\S+\.py\b|\bcli/\S+\.(?:js|json)\b', output)
438
+ files_str = ", ".join(dict.fromkeys(_files_re))[:300]
439
+
440
+ # Post-execution: publish to npm if configured and version was bumped
441
+ if cfg.sentinel_dev_auto_publish and new_version:
442
+ published = _run_npm_publish(repo_path, base_env, on_progress)
443
+ if published:
444
+ _record("published", note=summary, files=files_str, commit=new_version)
445
+ return "published", new_version
446
+ _record("done", note=summary, files=files_str, commit=new_version)
447
+ return "done", new_version
448
+
449
+ _record("done", note=summary, files=files_str)
450
+ return "done", new_version or ""
451
+
452
+
453
+ def _fire_progress(line: str, on_progress) -> None:
454
+ """Translate a raw Claude output line to progress and fire the callback."""
455
+ if on_progress:
456
+ msg = _dev_progress_from_line(line)
457
+ if msg:
458
+ try:
459
+ on_progress(msg)
460
+ except Exception:
461
+ pass
462
+
463
+
464
+ def drop_escalation(project_dir: Path, description: str, source: str = "fix_engine/BOSS_ESCALATE",
465
+ source_fingerprint: str = "", submitter_user_id: str = "") -> Path:
466
+ """
467
+ Create a dev task file for an escalation from a child Claude instance or Boss.
468
+ Returns the path to the created file.
469
+ """
470
+ dev_tasks_dir = project_dir / "dev-tasks"
471
+ dev_tasks_dir.mkdir(exist_ok=True)
472
+ ts = int(time.time())
473
+ fp_part = source_fingerprint[:8] if source_fingerprint else "esc"
474
+ fname = f"bot-{fp_part}-{ts}.txt"
475
+ fpath = dev_tasks_dir / fname
476
+
477
+ lines = [
478
+ "TYPE: fix",
479
+ f"SOURCE: {source}",
480
+ ]
481
+ if source_fingerprint:
482
+ lines.append(f"SOURCE_FINGERPRINT: {source_fingerprint}")
483
+ if submitter_user_id:
484
+ lines.append(f"SUBMITTED_BY: ({submitter_user_id})")
485
+ lines.append(f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}")
486
+ lines.append("")
487
+ lines.append(description)
488
+ fpath.write_text("\n".join(lines), encoding="utf-8")
489
+ logger.info("Dev escalation dropped: %s", fname)
490
+ 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]