nexo-brain 2.5.0 → 2.6.0

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.
@@ -1,23 +1,26 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- NEXO Catch-Up — Runs at boot/wake to recover any missed scheduled tasks.
4
-
5
- Tasks are loaded dynamically from crons/manifest.json (single source of truth).
6
- Only scheduled crons (with hour/minute) are recovered — interval-based crons
7
- (immune, watchdog, auto-close) restart automatically via launchd/systemd.
2
+ """NEXO Catch-Up — recover missed core cron windows after boot/wake.
8
3
 
9
- Logic: For each scheduled task, check if its last successful run was before
10
- the most recent scheduled time. If so, run it now. Only marks success on exit 0.
11
- Uses cron/launchd weekday convention (0=Sunday) converted to Python (0=Monday).
4
+ Recovery is driven by the explicit manifest contract plus cron_runs.
5
+ Legacy .catchup-state.json is now only a fallback for pre-wrapper history.
12
6
  """
13
7
 
8
+ import fcntl
14
9
  import json
15
10
  import os
16
11
  import subprocess
17
12
  import sys
18
- from datetime import datetime, timedelta
13
+ from datetime import datetime
19
14
  from pathlib import Path
20
15
 
16
+ _SCRIPT_DIR = Path(__file__).resolve().parent
17
+ _DEFAULT_RUNTIME_ROOT = _SCRIPT_DIR.parent
18
+ _runtime_root = Path(os.environ.get("NEXO_CODE", str(_DEFAULT_RUNTIME_ROOT)))
19
+ if str(_runtime_root) not in sys.path:
20
+ sys.path.insert(0, str(_runtime_root))
21
+
22
+ from cron_recovery import catchup_candidates
23
+
21
24
  HOME = Path.home()
22
25
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(HOME / ".nexo")))
23
26
 
@@ -48,8 +51,10 @@ LOG_DIR = NEXO_HOME / "logs"
48
51
  LOG_DIR.mkdir(parents=True, exist_ok=True)
49
52
  LOG_FILE = LOG_DIR / "catchup.log"
50
53
  STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
54
+ LOCK_FILE = NEXO_HOME / "operations" / ".catchup.lock"
51
55
 
52
56
  SCRIPTS = NEXO_HOME / "scripts"
57
+ WRAPPER = SCRIPTS / "nexo-cron-wrapper.sh"
53
58
 
54
59
  # Resolve Python: prefer NEXO's venv, then the same Python running this script
55
60
  def _resolve_python() -> str:
@@ -68,52 +73,6 @@ def _resolve_python() -> str:
68
73
  return sys.executable
69
74
 
70
75
  NEXO_PYTHON = _resolve_python()
71
- NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
72
- # Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
73
- _manifest_home = NEXO_HOME / "crons" / "manifest.json"
74
- _manifest_code = NEXO_CODE / "crons" / "manifest.json"
75
- MANIFEST = _manifest_home if _manifest_home.exists() else _manifest_code
76
-
77
-
78
- def _load_tasks_from_manifest() -> list[tuple]:
79
- """Read scheduled tasks from manifest.json — single source of truth.
80
-
81
- Only includes crons with a schedule (hour/minute). Excludes interval-based
82
- crons (immune, watchdog, auto-close) and run_at_load (catchup itself).
83
- Returns: list of (name, hour, minute, python_or_bash, script, weekday)
84
- """
85
- if not MANIFEST.exists():
86
- log(f"WARNING: manifest not found at {MANIFEST}, using empty task list")
87
- return []
88
-
89
- with open(MANIFEST) as f:
90
- data = json.load(f)
91
-
92
- tasks = []
93
- for cron in data.get("crons", []):
94
- schedule = cron.get("schedule")
95
- if not schedule or "hour" not in schedule:
96
- continue # Skip interval-based and run_at_load crons
97
- if cron["id"] == "catchup":
98
- continue # Don't catch up ourselves
99
-
100
- script = cron["script"]
101
- script_type = cron.get("type", "python")
102
- interpreter = NEXO_PYTHON if script_type == "python" else "/bin/bash"
103
- weekday = schedule.get("weekday")
104
-
105
- tasks.append((
106
- cron["id"],
107
- schedule["hour"],
108
- schedule["minute"],
109
- interpreter,
110
- Path(script).name,
111
- weekday,
112
- ))
113
-
114
- # Sort by hour, minute for correct execution order
115
- tasks.sort(key=lambda t: (t[1], t[2]))
116
- return tasks
117
76
 
118
77
 
119
78
  def log(msg: str):
@@ -138,59 +97,48 @@ def save_state(state: dict):
138
97
  STATE_FILE.write_text(json.dumps(state, indent=2))
139
98
 
140
99
 
141
- def last_scheduled_time(hour: int, minute: int, weekday: int = None) -> datetime:
142
- """Calculate the most recent time this task should have run."""
143
- now = datetime.now()
144
- today_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
145
-
146
- if weekday is not None:
147
- # Weekly task — find the most recent matching weekday
148
- # Manifest uses cron/launchd convention: 0=Sunday, 6=Saturday
149
- # Python datetime.weekday() uses: 0=Monday, 6=Sunday
150
- # Convert: manifest 0 (Sun) -> python 6, manifest 1 (Mon) -> python 0, etc.
151
- py_weekday = (weekday - 1) % 7
152
- days_since = (now.weekday() - py_weekday) % 7
153
- target = now - timedelta(days=days_since)
154
- target = target.replace(hour=hour, minute=minute, second=0, microsecond=0)
155
- if target > now:
156
- target -= timedelta(weeks=1)
157
- return target
158
-
159
- # Daily task
160
- if today_at <= now:
161
- return today_at
162
- else:
163
- return today_at - timedelta(days=1)
164
-
100
+ def _resolve_runtime_command(script_type: str) -> str:
101
+ if script_type == "shell":
102
+ return "/bin/bash"
103
+ if script_type == "node":
104
+ return "node"
105
+ if script_type == "php":
106
+ return "php"
107
+ return NEXO_PYTHON
165
108
 
166
- def should_run(task_name: str, hour: int, minute: int, state: dict, weekday: int = None) -> bool:
167
- """Check if task needs catch-up: last run was before last scheduled time."""
168
- last_run_str = state.get(task_name)
169
- last_scheduled = last_scheduled_time(hour, minute, weekday)
170
-
171
- if not last_run_str:
172
- # Never ran — should run
173
- return True
174
109
 
110
+ def _acquire_lock():
111
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
112
+ handle = LOCK_FILE.open("w")
175
113
  try:
176
- last_run = datetime.fromisoformat(last_run_str)
177
- except ValueError:
178
- return True
179
-
180
- return last_run < last_scheduled
114
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
115
+ except BlockingIOError:
116
+ handle.close()
117
+ return None
118
+ handle.write(str(os.getpid()))
119
+ handle.flush()
120
+ return handle
181
121
 
182
122
 
183
- def run_task(name: str, python: str, script: str, state: dict) -> bool:
123
+ def run_task(candidate: dict, state: dict) -> bool:
184
124
  """Execute a task and update state."""
185
- script_path = str(SCRIPTS / script)
125
+ name = candidate["cron_id"]
126
+ script_name = Path(candidate["script"]).name
127
+ script_path = str(SCRIPTS / script_name)
186
128
  if not Path(script_path).exists():
187
129
  log(f" SKIP {name}: script not found ({script_path})")
188
130
  return False
189
131
 
190
- log(f" RUNNING {name}: {script}")
132
+ runtime_cmd = _resolve_runtime_command(candidate.get("type", "python"))
133
+ if WRAPPER.exists():
134
+ command = ["/bin/bash", str(WRAPPER), name, runtime_cmd, script_path]
135
+ else:
136
+ command = [runtime_cmd, script_path]
137
+
138
+ log(f" RUNNING {name}: {script_name}")
191
139
  try:
192
140
  result = subprocess.run(
193
- [python, script_path],
141
+ command,
194
142
  capture_output=True, text=True, timeout=21600,
195
143
  env={**os.environ, "HOME": str(HOME), "NEXO_CATCHUP": "1"}
196
144
  )
@@ -205,7 +153,7 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
205
153
  log(f" stderr: {result.stderr[:300]}")
206
154
  return False
207
155
  except subprocess.TimeoutExpired:
208
- log(f" TIMEOUT {name} (300s)")
156
+ log(f" TIMEOUT {name} (21600s)")
209
157
  return False
210
158
  except Exception as e:
211
159
  log(f" ERROR {name}: {e}")
@@ -214,28 +162,45 @@ def run_task(name: str, python: str, script: str, state: dict) -> bool:
214
162
 
215
163
  def main():
216
164
  log("=== NEXO Catch-Up starting (boot/wake) ===")
217
- state = load_state()
165
+ lock_handle = _acquire_lock()
166
+ if lock_handle is None:
167
+ log("Catch-Up already running; skipping overlapping invocation.")
168
+ return
218
169
 
219
- # Read tasks from manifest — single source of truth
220
- tasks = _load_tasks_from_manifest()
170
+ state = load_state()
171
+ tasks = catchup_candidates()
221
172
 
222
173
  ran = 0
223
174
  skipped = 0
224
- for name, hour, minute, python, script, weekday in tasks:
225
- if should_run(name, hour, minute, state, weekday):
226
- log(f" {name} missed scheduled run, catching up...")
227
- if run_task(name, python, script, state):
175
+ skipped_out_of_window = 0
176
+ try:
177
+ for candidate in tasks:
178
+ name = candidate["cron_id"]
179
+ if not candidate.get("missed"):
180
+ skipped += 1
181
+ continue
182
+ if not candidate.get("within_window"):
183
+ skipped_out_of_window += 1
184
+ log(
185
+ f" SKIP {name}: missed window is {candidate['age_seconds']}s old "
186
+ f"(max_catchup_age={candidate['contract']['max_catchup_age']}s)"
187
+ )
188
+ continue
189
+ due_at = candidate["last_due_at"].astimezone().strftime("%Y-%m-%d %H:%M")
190
+ log(f" {name} — missed scheduled run due at {due_at}, catching up...")
191
+ if run_task(candidate, state):
228
192
  ran += 1
229
- else:
230
- skipped += 1
193
+ finally:
194
+ lock_handle.close()
231
195
 
232
- if ran == 0:
196
+ if ran == 0 and skipped_out_of_window == 0:
233
197
  log("All tasks up to date, nothing to catch up.")
234
198
  elif ran >= 3:
235
199
  # Many tasks caught up — ask CLI to assess system state
236
200
  _cli_post_catchup_assessment(ran, skipped, state)
237
201
  else:
238
- log(f"Caught up {ran} tasks, {skipped} already current.")
202
+ suffix = f", {skipped_out_of_window} outside recovery window" if skipped_out_of_window else ""
203
+ log(f"Caught up {ran} tasks, {skipped} already current{suffix}.")
239
204
 
240
205
  log("=== Catch-Up complete ===")
241
206
 
@@ -34,36 +34,90 @@ SANDBOX_DIR = CLAUDE_DIR / "sandbox" / "workspace"
34
34
  MAX_CONSECUTIVE_FAILURES = 3
35
35
  MAX_SNAPSHOTS = 8
36
36
 
37
- # ── Safe zones for AUTO execution ────────────────────────────────────────
38
- # "review" mode (owner): broader zones, but nothing executes without approval
39
- # "auto" mode (public users): restricted to user scripts and plugins ONLY
40
- AUTO_SAFE_PREFIXES = [
41
- str(CLAUDE_DIR / "scripts") + "/",
42
- str(CLAUDE_DIR / "brain") + "/",
43
- str(NEXO_CODE / "plugins") + "/",
44
- str(CLAUDE_DIR / "logs") + "/",
45
- str(CLAUDE_DIR / "coordination") + "/",
46
- ]
47
-
48
- # Public mode: user scripts and plugins only — NEVER core code
49
- AUTO_SAFE_PREFIXES_PUBLIC = [
50
- str(CLAUDE_DIR / "scripts") + "/",
51
- str(CLAUDE_DIR / "plugins") + "/",
52
- ]
53
-
54
- # ── Immutable files — NEVER touch (applies to ALL modes) ────────────────
55
- IMMUTABLE_FILES = {
56
- "db.py", "server.py", "plugin_loader.py", "nexo-watchdog.sh",
57
- "cortex-wrapper.py", "CLAUDE.md", "personality.md",
58
- "user-profile.md", "evolution_cycle.py",
59
- # Core cognitive engine — never auto-modified
60
- "cognitive.py", "knowledge_graph.py", "storage_router.py",
61
- # Core tools — never auto-modified
62
- "tools_sessions.py", "tools_coordination.py", "tools_reminders.py",
63
- "tools_reminders_crud.py", "tools_learnings.py", "tools_credentials.py",
64
- "tools_task_history.py", "tools_menu.py",
37
+ # ── Immutable files split by risk tier ────────────────────────────────
38
+ # These remain locked even in managed mode because they can break bootstrap,
39
+ # persistence, or the evolution engine itself.
40
+ GLOBAL_IMMUTABLE_FILES = {
41
+ "db.py",
42
+ "server.py",
43
+ "plugin_loader.py",
44
+ "nexo-watchdog.sh",
45
+ "cortex-wrapper.py",
46
+ "CLAUDE.md",
47
+ "personality.md",
48
+ "user-profile.md",
49
+ "evolution_cycle.py",
50
+ "storage_router.py",
65
51
  }
66
52
 
53
+ # Managed mode may autoevolve behavior/tooling modules, but auto/review keep
54
+ # these guarded to stay conservative for public installs.
55
+ STANDARD_MODE_IMMUTABLE_FILES = {
56
+ "cognitive.py",
57
+ "knowledge_graph.py",
58
+ "tools_sessions.py",
59
+ "tools_coordination.py",
60
+ "tools_reminders.py",
61
+ "tools_reminders_crud.py",
62
+ "tools_learnings.py",
63
+ "tools_credentials.py",
64
+ "tools_task_history.py",
65
+ "tools_menu.py",
66
+ }
67
+
68
+
69
+ def _repo_root() -> Path | None:
70
+ candidate = NEXO_CODE.parent
71
+ if (candidate / "package.json").exists():
72
+ return candidate
73
+ return None
74
+
75
+
76
+ def _public_safe_prefixes() -> list[str]:
77
+ return [
78
+ str(CLAUDE_DIR / "scripts") + "/",
79
+ str(CLAUDE_DIR / "plugins") + "/",
80
+ str(CLAUDE_DIR / "skills") + "/",
81
+ str(CLAUDE_DIR / "skills-runtime") + "/",
82
+ ]
83
+
84
+
85
+ def _managed_safe_prefixes() -> list[str]:
86
+ prefixes = [
87
+ str(CLAUDE_DIR / "scripts") + "/",
88
+ str(CLAUDE_DIR / "plugins") + "/",
89
+ str(CLAUDE_DIR / "brain") + "/",
90
+ str(CLAUDE_DIR / "coordination") + "/",
91
+ str(CLAUDE_DIR / "logs") + "/",
92
+ str(CLAUDE_DIR / "skills") + "/",
93
+ str(CLAUDE_DIR / "skills-core") + "/",
94
+ str(CLAUDE_DIR / "skills-runtime") + "/",
95
+ str(NEXO_CODE) + "/",
96
+ ]
97
+ repo_root = _repo_root()
98
+ if repo_root:
99
+ for rel in ("bin", "docs", "templates", "tests"):
100
+ prefixes.append(str(repo_root / rel) + "/")
101
+ return prefixes
102
+
103
+
104
+ def _normalize_mode(mode: str) -> str:
105
+ value = str(mode or "auto").strip().lower()
106
+ aliases = {
107
+ "owner": "managed",
108
+ "core": "managed",
109
+ "hybrid": "managed",
110
+ "manual": "review",
111
+ }
112
+ return aliases.get(value, value if value in {"auto", "review", "managed"} else "auto")
113
+
114
+
115
+ def _immutable_files_for_mode(mode: str) -> set[str]:
116
+ normalized = _normalize_mode(mode)
117
+ if normalized == "managed":
118
+ return set(GLOBAL_IMMUTABLE_FILES)
119
+ return set(GLOBAL_IMMUTABLE_FILES) | set(STANDARD_MODE_IMMUTABLE_FILES)
120
+
67
121
  # ── Claude CLI path ──────────────────────────────────────────────────────
68
122
  def _resolve_claude_cli() -> Path:
69
123
  """Find claude CLI: saved path > PATH > common locations."""
@@ -162,16 +216,18 @@ def call_claude_cli(prompt: str) -> str:
162
216
  # ── File safety validation ───────────────────────────────────────────────
163
217
  def is_safe_path(filepath: str, mode: str = "auto") -> bool:
164
218
  """Check if a file path is within safe zones and not immutable.
165
- mode='auto' (public): restricted to scripts/ and plugins/ only.
166
- mode='review' (owner): broader zones but nothing executes without approval anyway.
219
+ mode='auto' (public): restricted to personal automation surfaces.
220
+ mode='managed' (owner): broader repo/core surfaces with rollback.
221
+ mode='review': broader zones for proposal validation, but no execution.
167
222
  """
168
223
  expanded = str(Path(filepath).expanduser().resolve())
169
224
  filename = Path(expanded).name
225
+ mode = _normalize_mode(mode)
170
226
 
171
- if filename in IMMUTABLE_FILES:
227
+ if filename in _immutable_files_for_mode(mode):
172
228
  return False
173
229
 
174
- prefixes = AUTO_SAFE_PREFIXES if mode == "review" else AUTO_SAFE_PREFIXES_PUBLIC
230
+ prefixes = _managed_safe_prefixes() if mode in {"managed", "review"} else _public_safe_prefixes()
175
231
  for prefix in prefixes:
176
232
  resolved_prefix = str(Path(prefix).expanduser().resolve())
177
233
  if expanded.startswith(resolved_prefix):
@@ -218,13 +274,13 @@ def validate_syntax(filepath: str) -> tuple[bool, str]:
218
274
 
219
275
 
220
276
  # ── Apply a single change operation ──────────────────────────────────────
221
- def apply_change(change: dict) -> tuple[bool, str]:
277
+ def apply_change(change: dict, mode: str = "auto") -> tuple[bool, str]:
222
278
  """Apply a single file change operation. Returns (success, message)."""
223
279
  filepath = str(Path(change["file"]).expanduser())
224
280
  operation = change.get("operation", "")
225
281
  content = change.get("content", "")
226
282
 
227
- if not is_safe_path(filepath):
283
+ if not is_safe_path(filepath, mode=mode):
228
284
  return False, f"BLOCKED: {filepath} is outside safe zones or immutable"
229
285
 
230
286
  try:
@@ -269,7 +325,7 @@ def apply_change(change: dict) -> tuple[bool, str]:
269
325
 
270
326
 
271
327
  # ── Execute AUTO proposals ───────────────────────────────────────────────
272
- def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection) -> dict:
328
+ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection, mode: str = "auto") -> dict:
273
329
  """Execute an AUTO proposal with snapshot/apply/validate/rollback."""
274
330
  changes = proposal.get("changes", [])
275
331
  if not changes:
@@ -278,7 +334,7 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
278
334
  # Validate all paths first
279
335
  for change in changes:
280
336
  filepath = str(Path(change["file"]).expanduser())
281
- if not is_safe_path(filepath):
337
+ if not is_safe_path(filepath, mode=mode):
282
338
  return {"status": "blocked", "reason": f"Unsafe path: {filepath}"}
283
339
 
284
340
  # Collect files to snapshot (existing files only)
@@ -299,7 +355,7 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
299
355
  all_results = []
300
356
  try:
301
357
  for change in changes:
302
- success, msg = apply_change(change)
358
+ success, msg = apply_change(change, mode=mode)
303
359
  all_results.append(msg)
304
360
  log(f" {msg}")
305
361
  if not success:
@@ -343,57 +399,102 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
343
399
  log(f" Removed created file: {filepath}")
344
400
 
345
401
  return {
346
- "status": "failed",
402
+ "status": "rolled_back",
347
403
  "snapshot_ref": snapshot_ref,
348
404
  "files_changed": [],
349
405
  "test_result": f"ROLLBACK: {e}; " + "; ".join(all_results),
350
406
  }
351
407
 
352
408
 
353
- # ── Review followup for owner mode ──────────────────────────────────────
354
- def _create_review_followup(conn: sqlite3.Connection, cycle_num: int,
355
- items: list[dict], analysis: str):
356
- """Create a followup summarizing Evolution proposals for owner review."""
409
+ # ── Followups for managed/review modes ──────────────────────────────────
410
+ def _insert_followup(conn: sqlite3.Connection, followup_id: str, description: str,
411
+ verification: str, due_date: str | None = None):
412
+ now_epoch = datetime.now().timestamp()
413
+ conn.execute(
414
+ "INSERT OR REPLACE INTO followups (id, description, date, status, verification, created_at, updated_at) "
415
+ "VALUES (?, ?, ?, 'PENDING', ?, ?, ?)",
416
+ (followup_id, description, due_date, verification, now_epoch, now_epoch)
417
+ )
418
+ conn.commit()
419
+
420
+
421
+ def _create_cycle_followup(conn: sqlite3.Connection, cycle_num: int,
422
+ items: list[dict], analysis: str, mode: str):
423
+ """Create a followup summarizing pending proposals or owner review items."""
357
424
  tomorrow = (date.today() + timedelta(days=1)).isoformat()
358
425
  followup_id = f"NF-EVO-C{cycle_num}"
359
426
 
360
427
  public_items = [i for i in items if i.get("scope") == "public"]
361
428
  local_items = [i for i in items if i.get("scope") != "public"]
362
429
 
363
- lines = [f"Evolution Cycle #{cycle_num} {len(items)} proposals to review."]
430
+ title = "proposals to review" if mode == "review" else "items needing attention"
431
+ lines = [f"Evolution Cycle #{cycle_num} — {len(items)} {title}."]
364
432
  lines.append(f"Analysis: {analysis[:200]}")
365
433
  lines.append("")
366
434
 
367
435
  if public_items:
368
436
  lines.append(f"FOR EVERYONE ({len(public_items)}):")
369
437
  for i, item in enumerate(public_items, 1):
370
- lines.append(f" {i}. [{item['dimension']}] {item['action'][:120]}")
438
+ status = item.get("status", "proposed").upper()
439
+ lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
371
440
  lines.append(f" Why: {item['reasoning'][:100]}")
441
+ if item.get("detail"):
442
+ lines.append(f" Detail: {item['detail'][:160]}")
372
443
  lines.append("")
373
444
 
374
445
  if local_items:
375
446
  lines.append(f"FOR YOU ONLY ({len(local_items)}):")
376
447
  for i, item in enumerate(local_items, 1):
377
- lines.append(f" {i}. [{item['dimension']}] {item['action'][:120]}")
448
+ status = item.get("status", "proposed").upper()
449
+ lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
378
450
  lines.append(f" Why: {item['reasoning'][:100]}")
451
+ if item.get("detail"):
452
+ lines.append(f" Detail: {item['detail'][:160]}")
379
453
 
380
454
  description = "\n".join(lines)
381
455
 
382
456
  try:
383
- now_epoch = datetime.now().timestamp()
384
- conn.execute(
385
- "INSERT OR REPLACE INTO followups (id, description, date, status, verification, created_at, updated_at) "
386
- "VALUES (?, ?, ?, 'pending', ?, ?, ?)",
387
- (followup_id, description, tomorrow,
388
- f"SELECT * FROM evolution_log WHERE cycle_number={cycle_num}",
389
- now_epoch, now_epoch)
457
+ _insert_followup(
458
+ conn,
459
+ followup_id,
460
+ description,
461
+ f"SELECT * FROM evolution_log WHERE cycle_number={cycle_num}",
462
+ due_date=tomorrow,
390
463
  )
391
- conn.commit()
392
464
  log(f" Followup {followup_id} created for {tomorrow}")
393
465
  except Exception as e:
394
466
  log(f" WARN: Failed to create followup: {e}")
395
467
 
396
468
 
469
+ def _create_failure_followup(conn: sqlite3.Connection, cycle_num: int, log_id: int,
470
+ proposal: dict, result: dict):
471
+ """Create an incident-style followup for a failed or blocked AUTO proposal."""
472
+ followup_id = f"NF-EVO-L{log_id}"
473
+ lines = [
474
+ f"Evolution AUTO proposal failed in cycle #{cycle_num}.",
475
+ f"Action: {proposal.get('action', '')[:200]}",
476
+ f"Dimension: {proposal.get('dimension', 'other')}",
477
+ f"Status: {result.get('status', 'failed')}",
478
+ f"Reason: {(result.get('reason') or result.get('test_result') or 'unknown')[:400]}",
479
+ ]
480
+ snapshot_ref = result.get("snapshot_ref")
481
+ if snapshot_ref:
482
+ lines.append(f"Snapshot: {snapshot_ref}")
483
+ description = "\n".join(lines)
484
+
485
+ try:
486
+ _insert_followup(
487
+ conn,
488
+ followup_id,
489
+ description,
490
+ f"SELECT * FROM evolution_log WHERE id={log_id}",
491
+ due_date=(date.today() + timedelta(days=1)).isoformat(),
492
+ )
493
+ log(f" Failure followup {followup_id} created")
494
+ except Exception as e:
495
+ log(f" WARN: Failed to create failure followup: {e}")
496
+
497
+
397
498
  # ── Main run ─────────────────────────────────────────────────────────────
398
499
  def run():
399
500
  log("=" * 60)
@@ -482,14 +583,12 @@ def run():
482
583
  max_auto = max_auto_changes(objective.get("total_evolutions", 0))
483
584
  auto_count = 0
484
585
  auto_applied = 0
485
- evolution_mode = objective.get("evolution_mode", "auto") # "auto" (public) or "review" (owner)
586
+ evolution_mode = _normalize_mode(objective.get("evolution_mode", "auto"))
486
587
 
487
588
  conn = sqlite3.connect(str(NEXO_DB), timeout=10)
488
589
  conn.execute("PRAGMA busy_timeout=5000")
489
590
 
490
- # In "review" mode: log everything as pending_review, create followup
491
- # In "auto" mode: execute AUTO proposals, log PROPOSE as proposed
492
- review_items = []
591
+ followup_items = []
493
592
 
494
593
  for p in proposals:
495
594
  classification = p.get("classification", "propose")
@@ -499,30 +598,29 @@ def run():
499
598
  scope = p.get("scope", "local") # "public" or "local"
500
599
 
501
600
  if evolution_mode == "review":
502
- # Owner mode: nothing executes, everything queued for review
503
601
  log(f" QUEUED [{scope}]: {action[:80]}")
504
602
  conn.execute(
505
603
  "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
506
604
  "reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
507
605
  (cycle_num, dimension, action, classification, reasoning, "pending_review")
508
606
  )
509
- review_items.append({
607
+ followup_items.append({
510
608
  "dimension": dimension,
511
609
  "action": action,
512
610
  "reasoning": reasoning,
513
611
  "scope": scope,
514
612
  "classification": classification,
613
+ "status": "pending_review",
515
614
  })
516
615
 
517
616
  elif classification == "auto" and auto_count < max_auto:
518
- # Public mode: execute AUTO proposals
519
617
  auto_count += 1
520
618
  log(f" AUTO #{auto_count}/{max_auto}: {action[:80]}")
521
619
 
522
- result = execute_auto_proposal(p, cycle_num, conn)
620
+ result = execute_auto_proposal(p, cycle_num, conn, mode=evolution_mode)
523
621
  status = result["status"]
524
622
 
525
- conn.execute(
623
+ cur = conn.execute(
526
624
  "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
527
625
  "reasoning, status, files_changed, snapshot_ref, test_result) "
528
626
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -531,16 +629,20 @@ def run():
531
629
  result.get("snapshot_ref", ""),
532
630
  result.get("test_result", ""))
533
631
  )
632
+ log_id = cur.lastrowid
534
633
 
535
634
  if status == "applied":
536
635
  auto_applied += 1
537
636
  log(f" APPLIED successfully")
538
637
  elif status == "blocked":
539
- log(f" BLOCKED: {result.get('test_result', '')}")
638
+ detail = result.get("reason") or result.get("test_result", "")
639
+ log(f" BLOCKED: {detail[:100]}")
640
+ _create_failure_followup(conn, cycle_num, log_id, p, result)
540
641
  elif status == "skipped":
541
642
  log(f" SKIPPED: {result.get('reason', '')}")
542
643
  else:
543
- log(f" FAILED: {result.get('test_result', '')[:100]}")
644
+ log(f" ROLLED BACK: {result.get('test_result', '')[:100]}")
645
+ _create_failure_followup(conn, cycle_num, log_id, p, result)
544
646
 
545
647
  else:
546
648
  # PROPOSE or over auto limit
@@ -555,12 +657,20 @@ def run():
555
657
  "reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
556
658
  (cycle_num, dimension, action, classification, reasoning, "proposed")
557
659
  )
660
+ if evolution_mode in {"review", "managed"}:
661
+ followup_items.append({
662
+ "dimension": dimension,
663
+ "action": action,
664
+ "reasoning": reasoning,
665
+ "scope": scope,
666
+ "classification": classification,
667
+ "status": "proposed",
668
+ })
558
669
 
559
670
  conn.commit()
560
671
 
561
- # In review mode: create followup for owner
562
- if evolution_mode == "review" and review_items:
563
- _create_review_followup(conn, cycle_num, review_items, response.get("analysis", ""))
672
+ if evolution_mode in {"review", "managed"} and followup_items:
673
+ _create_cycle_followup(conn, cycle_num, followup_items, response.get("analysis", ""), evolution_mode)
564
674
 
565
675
  # Update metrics
566
676
  scores = response.get("dimension_scores", {})
@@ -591,6 +701,7 @@ def run():
591
701
  objective.setdefault("history", []).insert(0, {
592
702
  "cycle": cycle_num,
593
703
  "date": str(date.today()),
704
+ "mode": evolution_mode,
594
705
  "proposals": len(proposals),
595
706
  "auto_count": auto_count,
596
707
  "auto_applied": auto_applied,