nexo-brain 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -535,8 +535,12 @@ That's it. No need to run `claude` manually. Your operator will greet you immedi
535
535
  | MCP server | 147+ tools for memory, cognition, learning, guard | NEXO_HOME/ |
536
536
  | Plugins | Guard, episodic memory, cognitive memory, entities, preferences, update, etc. | Code: src/plugins/, Personal: NEXO_HOME/plugins/ |
537
537
  | Hooks (7) | SessionStart, Stop, PostToolUse, PreCompact, PostCompact | NEXO_HOME/hooks/ |
538
- | Nervous system | 15 autonomous processes (decay, sleep, audit, evolution, watchdog, etc.) | NEXO_HOME/scripts/ |
539
- | Dashboard | Web UI at localhost:6174 (6 pages) | NEXO_HOME/dashboard/ |
538
+ | Nervous system | 17 autonomous processes (decay, sleep, audit, evolution, watchdog, orchestrator, dashboard, etc.) | NEXO_HOME/scripts/ |
539
+ | Dashboard | Web UI at localhost:6174 (23 modules, dark theme) — opt-in, always-on | NEXO_HOME/dashboard/ |
540
+ | Runtime CLI | `nexo` command: scripts, doctor, skills, update | NEXO_HOME/bin/ |
541
+ | Doctor | Unified diagnostics: boot/runtime/deep tiers, `--fix` mode | src/doctor/ |
542
+ | Skills v2 | Executable skills with guide/execute/hybrid modes, approval levels | NEXO_HOME/skills/ |
543
+ | Day Orchestrator | Autonomous cycles every 15 min (8:00-23:00) — opt-in | LaunchAgent |
540
544
  | CLAUDE.md | Complete operator instructions (Codex, hooks, guard, trust, memory) | ~/.claude/CLAUDE.md |
541
545
  | Schedule config | schedule.json with customizable process times and timezone | NEXO_HOME/config/ |
542
546
  | Auto-update | Non-blocking startup check (5s max), opt-out via schedule.json | Built into server startup |
@@ -789,6 +793,15 @@ If NEXO Brain is useful to you, consider:
789
793
 
790
794
  ## Changelog
791
795
 
796
+ ### v2.5.0 — Runtime CLI, Doctor, Skills v2, Day Orchestrator (2026-04-03)
797
+ - **Runtime CLI** (`nexo`): New operational CLI separate from installer. `nexo scripts list/run/doctor/call` for personal scripts, `nexo doctor` for diagnostics, `nexo skills apply` for executable skills, `nexo update` for one-step sync.
798
+ - **Unified Doctor**: Modular diagnostic system with boot/runtime/deep tiers. Report-only by default, deterministic `--fix` mode. MCP tool `nexo_doctor`. LaunchAgent schedule drift detection and reconciliation.
799
+ - **Skills v2**: Executable skills with guide/execute/hybrid modes. Security levels (read-only/local/remote) with explicit approval. Core vs personal vs community directories. Deep Sleep auto-evolution integration.
800
+ - **Day Orchestrator**: Autonomous NEXO cycles every 15 min (8:00-23:00). Launches Claude Code headless with full MCP. Checks followups, emails, infra — acts autonomously, emails user only when needed. Opt-in.
801
+ - **Dashboard always-on**: Web UI at localhost:6174 as persistent LaunchAgent. 23 modules, Jinja2 templating, dark theme. Opt-in.
802
+ - **Personal Scripts Framework**: Auto-discovery in NEXO_HOME/scripts/, inline metadata, runtime detection, forbidden-pattern validation, vendorable helper, template.
803
+ - Configurable operator name (UserContext singleton), watchdog normalized to 30 min, LaunchAgent drift fix.
804
+
792
805
  ### v2.4.0 — Skills, Cron Scheduler, Security, Full Audit (2026-04-03)
793
806
  - **Skill Auto-Creation**: Deep Sleep extracts reusable procedures from sessions. Content stored as markdown with steps and gotchas. Trust pipeline with autonomous quality control.
794
807
  - **Cron Scheduler**: execution tracking (`cron_runs` table), `nexo_schedule_status` and `nexo_schedule_add` MCP tools, universal cron wrapper for all processes.
package/bin/nexo-brain.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  const { execSync, spawnSync } = require("child_process");
18
+ const crypto = require("crypto");
18
19
  const fs = require("fs");
19
20
  const path = require("path");
20
21
  const readline = require("readline");
@@ -59,6 +60,33 @@ function log(msg) {
59
60
  console.log(` ${msg}`);
60
61
  }
61
62
 
63
+ function syncWatchdogHashRegistry(nexoHome) {
64
+ try {
65
+ const watchdogPath = path.join(nexoHome, "scripts", "nexo-watchdog.sh");
66
+ if (!fs.existsSync(watchdogPath)) return;
67
+
68
+ const registryPath = path.join(nexoHome, "scripts", ".watchdog-hashes");
69
+ const entries = new Map();
70
+ if (fs.existsSync(registryPath)) {
71
+ for (const line of fs.readFileSync(registryPath, "utf8").split(/\r?\n/)) {
72
+ if (!line.includes("|")) continue;
73
+ const [filePath, expectedHash] = line.split("|");
74
+ if (filePath) entries.set(filePath, expectedHash || "");
75
+ }
76
+ }
77
+
78
+ const digest = crypto.createHash("sha256").update(fs.readFileSync(watchdogPath)).digest("hex");
79
+ entries.set(watchdogPath, digest);
80
+ const body = Array.from(entries.entries())
81
+ .sort(([a], [b]) => a.localeCompare(b))
82
+ .map(([filePath, hash]) => `${filePath}|${hash}`)
83
+ .join("\n");
84
+ fs.writeFileSync(registryPath, `${body}\n`);
85
+ } catch (err) {
86
+ log(`WARN: could not sync watchdog hash registry: ${err.message}`);
87
+ }
88
+ }
89
+
62
90
  // ══════════════════════════════════════════════════════════════════════════════
63
91
  // CORE PROCESS & HOOK DEFINITIONS
64
92
  // All 13 nightly/periodic processes and all 8 core hooks that make NEXO functional.
@@ -1370,7 +1398,7 @@ async function main() {
1370
1398
  objective: "Improve operational excellence and reduce repeated errors",
1371
1399
  focus_areas: ["error_prevention", "proactivity", "memory_quality"],
1372
1400
  evolution_enabled: true,
1373
- evolution_mode: "review",
1401
+ evolution_mode: "auto",
1374
1402
  dimensions: {
1375
1403
  episodic_memory: { current: 0, target: 90 },
1376
1404
  autonomy: { current: 0, target: 80 },
@@ -1502,6 +1530,7 @@ async function main() {
1502
1530
  fs.readdirSync(scriptsDest).filter(f => f.endsWith(".sh")).forEach(f => {
1503
1531
  fs.chmodSync(path.join(scriptsDest, f), "755");
1504
1532
  });
1533
+ syncWatchdogHashRegistry(NEXO_HOME);
1505
1534
  }
1506
1535
 
1507
1536
  // Core skills are shipped separately from personal skills.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO — Cognitive co-operator for Claude Code. Memory, emotional intelligence, overnight learning (Deep Sleep), cron management, trust scoring, and adaptive calibration.",
6
6
  "bin": {
@@ -39,7 +39,12 @@
39
39
  "cron-manifest",
40
40
  "session-tone",
41
41
  "mood-tracking",
42
- "productivity-analysis"
42
+ "productivity-analysis",
43
+ "runtime-cli",
44
+ "doctor",
45
+ "executable-skills",
46
+ "day-orchestrator",
47
+ "work-continuity"
43
48
  ],
44
49
  "author": "NEXO Brain <info@nexo-brain.com>",
45
50
  "license": "AGPL-3.0",
@@ -8,6 +8,7 @@ This is separate from plugins/update.py which handles MANUAL updates with rollba
8
8
  """
9
9
 
10
10
  import json
11
+ import hashlib
11
12
  import os
12
13
  import re
13
14
  import subprocess
@@ -56,6 +57,30 @@ def _write_last_check(data: dict):
56
57
  _log(f"Failed to write last-check file: {e}")
57
58
 
58
59
 
60
+ def _sync_watchdog_hash_registry():
61
+ """Keep the immutable-hash registry aligned with the installed watchdog script."""
62
+ try:
63
+ watchdog_file = NEXO_HOME / "scripts" / "nexo-watchdog.sh"
64
+ if not watchdog_file.exists():
65
+ return
66
+ registry_file = NEXO_HOME / "scripts" / ".watchdog-hashes"
67
+ entries: dict[str, str] = {}
68
+ if registry_file.exists():
69
+ for line in registry_file.read_text().splitlines():
70
+ if "|" not in line:
71
+ continue
72
+ filepath, expected = line.split("|", 1)
73
+ if filepath:
74
+ entries[filepath] = expected
75
+ actual_hash = hashlib.sha256(watchdog_file.read_bytes()).hexdigest()
76
+ entries[str(watchdog_file)] = actual_hash
77
+ registry_file.write_text(
78
+ "\n".join(f"{filepath}|{digest}" for filepath, digest in sorted(entries.items())) + "\n"
79
+ )
80
+ except Exception as e:
81
+ _log(f"watchdog hash registry sync error: {e}")
82
+
83
+
59
84
  def _is_git_repo() -> bool:
60
85
  """Check if REPO_DIR is inside a git repository."""
61
86
  try:
@@ -811,13 +836,14 @@ def auto_update_check() -> dict:
811
836
  # Backfill evolution-objective.json for existing installs
812
837
  try:
813
838
  evo_obj_path = NEXO_HOME / "brain" / "evolution-objective.json"
839
+ from evolution_cycle import normalize_objective
814
840
  if not evo_obj_path.exists():
815
841
  (NEXO_HOME / "brain").mkdir(parents=True, exist_ok=True)
816
842
  default_objective = {
817
843
  "objective": "Improve operational excellence and reduce repeated errors",
818
844
  "focus_areas": ["error_prevention", "proactivity", "memory_quality"],
819
845
  "evolution_enabled": True,
820
- "evolution_mode": "review",
846
+ "evolution_mode": "auto",
821
847
  "dimensions": {
822
848
  "episodic_memory": {"current": 0, "target": 90},
823
849
  "autonomy": {"current": 0, "target": 80},
@@ -831,6 +857,12 @@ def auto_update_check() -> dict:
831
857
  }
832
858
  evo_obj_path.write_text(json.dumps(default_objective, indent=2))
833
859
  _log("Backfilled evolution-objective.json for existing install")
860
+ else:
861
+ raw_objective = json.loads(evo_obj_path.read_text())
862
+ normalized = normalize_objective(raw_objective)
863
+ if normalized != raw_objective:
864
+ evo_obj_path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False))
865
+ _log("Normalized legacy evolution-objective.json")
834
866
  except Exception as e:
835
867
  _log(f"evolution-objective.json backfill error: {e}")
836
868
 
@@ -853,6 +885,8 @@ def auto_update_check() -> dict:
853
885
  except Exception as e:
854
886
  _log(f"scripts backfill error: {e}")
855
887
 
888
+ _sync_watchdog_hash_registry()
889
+
856
890
  # Backfill runtime CLI modules for existing installs
857
891
  try:
858
892
  for fname in ("cli.py", "script_registry.py", "skills_runtime.py"):
package/src/cli.py CHANGED
@@ -30,6 +30,17 @@ from pathlib import Path
30
30
  NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
31
31
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
32
32
 
33
+
34
+ def _get_version() -> str:
35
+ """Read version from package.json automatically."""
36
+ for candidate in [NEXO_CODE.parent / "package.json", NEXO_HOME / "package.json"]:
37
+ try:
38
+ if candidate.is_file():
39
+ return json.loads(candidate.read_text()).get("version", "?")
40
+ except Exception:
41
+ continue
42
+ return "?"
43
+
33
44
  # Ensure src/ is on path for imports
34
45
  if str(NEXO_CODE) not in sys.path:
35
46
  sys.path.insert(0, str(NEXO_CODE))
@@ -357,6 +368,71 @@ def _update(args):
357
368
  return 0
358
369
 
359
370
 
371
+ def _service_control(service_name: str, action: str) -> int:
372
+ """Control a LaunchAgent/systemd service: on, off, status."""
373
+ import platform as plat
374
+
375
+ label = f"com.nexo.{service_name}"
376
+
377
+ if plat.system() != "Darwin":
378
+ print(f"Service control only supported on macOS for now.", file=sys.stderr)
379
+ return 1
380
+
381
+ plist_path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
382
+ uid = os.getuid()
383
+
384
+ if action == "status":
385
+ result = subprocess.run(
386
+ ["launchctl", "list"],
387
+ capture_output=True, text=True,
388
+ )
389
+ running = label in (result.stdout or "")
390
+ if running:
391
+ print(f"{service_name}: running")
392
+ else:
393
+ print(f"{service_name}: stopped")
394
+ return 0
395
+
396
+ if action == "on":
397
+ if not plist_path.is_file():
398
+ print(f"LaunchAgent not found: {plist_path}", file=sys.stderr)
399
+ print(f"Run 'nexo-brain' to install it, or enable it during setup.", file=sys.stderr)
400
+ return 1
401
+ subprocess.run(
402
+ ["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
403
+ capture_output=True,
404
+ )
405
+ result = subprocess.run(
406
+ ["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
407
+ capture_output=True, text=True,
408
+ )
409
+ if result.returncode == 0:
410
+ print(f"{service_name}: started")
411
+ else:
412
+ print(f"Failed to start {service_name}: {result.stderr.strip()}", file=sys.stderr)
413
+ return 1
414
+ return 0
415
+
416
+ if action == "off":
417
+ result = subprocess.run(
418
+ ["launchctl", "bootout", f"gui/{uid}", str(plist_path)],
419
+ capture_output=True, text=True,
420
+ )
421
+ print(f"{service_name}: stopped")
422
+ return 0
423
+
424
+ print(f"Unknown action: {action}. Use on, off, or status.", file=sys.stderr)
425
+ return 1
426
+
427
+
428
+ def _dashboard(args):
429
+ return _service_control("dashboard", args.action)
430
+
431
+
432
+ def _orchestrator(args):
433
+ return _service_control("day-orchestrator", args.action)
434
+
435
+
360
436
  def _doctor(args):
361
437
  """Run unified doctor diagnostics."""
362
438
  try:
@@ -482,8 +558,27 @@ def _skills_evolution(args):
482
558
  return 0
483
559
 
484
560
 
561
+ def _print_help():
562
+ v = _get_version()
563
+ print(f"""NEXO Runtime CLI v{v}
564
+
565
+ Commands:
566
+ nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
567
+ nexo scripts list|run|doctor|call Personal scripts
568
+ nexo skills list|apply|sync|approve Executable skills
569
+ nexo update Sync repo to NEXO_HOME
570
+ nexo dashboard on|off|status Web dashboard control
571
+ nexo orchestrator on|off|status Autonomous mode control
572
+
573
+ Run 'nexo <command> --help' for details.
574
+ Homepage: https://nexo-brain.com
575
+ GitHub: https://github.com/wazionapps/nexo""")
576
+
577
+
485
578
  def main():
486
- parser = argparse.ArgumentParser(prog="nexo", description="NEXO Runtime CLI")
579
+ parser = argparse.ArgumentParser(prog="nexo", description="NEXO Runtime CLI", add_help=False)
580
+ parser.add_argument("-h", "--help", action="store_true", help="Show help")
581
+ parser.add_argument("-v", "--version", action="store_true", help="Show version")
487
582
  sub = parser.add_subparsers(dest="command")
488
583
 
489
584
  # -- scripts --
@@ -560,8 +655,23 @@ def main():
560
655
  skills_evolution_p = skills_sub.add_parser("evolution", help="Evolution candidates")
561
656
  skills_evolution_p.add_argument("--json", action="store_true", help="JSON output")
562
657
 
658
+ # -- dashboard --
659
+ dashboard_parser = sub.add_parser("dashboard", help="Web dashboard control")
660
+ dashboard_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check dashboard")
661
+
662
+ # -- orchestrator --
663
+ orchestrator_parser = sub.add_parser("orchestrator", help="Autonomous mode control")
664
+ orchestrator_parser.add_argument("action", choices=["on", "off", "status"], help="Start, stop, or check orchestrator")
665
+
563
666
  args = parser.parse_args()
564
667
 
668
+ if args.help or (not args.command and not args.version):
669
+ _print_help()
670
+ return 0
671
+ if args.version:
672
+ print(f"nexo v{_get_version()}")
673
+ return 0
674
+
565
675
  if args.command == "scripts":
566
676
  if args.scripts_command == "list":
567
677
  return _scripts_list(args)
@@ -596,8 +706,12 @@ def main():
596
706
  else:
597
707
  skills_parser.print_help()
598
708
  return 0
709
+ elif args.command == "dashboard":
710
+ return _dashboard(args)
711
+ elif args.command == "orchestrator":
712
+ return _orchestrator(args)
599
713
  else:
600
- parser.print_help()
714
+ _print_help()
601
715
  return 0
602
716
 
603
717
 
@@ -34,14 +34,83 @@ PROMPT_FILE = _resolve_evolution_file("evolution-prompt.md")
34
34
  MAX_SNAPSHOTS = 8
35
35
 
36
36
 
37
+ def _normalize_dimensions(raw: dict | None) -> dict:
38
+ normalized = {}
39
+ for key, value in (raw or {}).items():
40
+ canonical_key = "agi" if key == "agi_readiness" else key
41
+ if isinstance(value, dict):
42
+ normalized[canonical_key] = {
43
+ "current": int(value.get("current", 0) or 0),
44
+ "target": int(value.get("target", 0) or 0),
45
+ }
46
+ else:
47
+ normalized[canonical_key] = {
48
+ "current": 0,
49
+ "target": int(value or 0),
50
+ }
51
+ return normalized
52
+
53
+
54
+ def normalize_objective(obj: dict | None) -> dict:
55
+ """Upgrade legacy objective files to the canonical schema."""
56
+ source = dict(obj or {})
57
+
58
+ if "evolution_mode" in source:
59
+ mode = str(source.get("evolution_mode") or "auto").strip().lower()
60
+ else:
61
+ legacy_mode = str(source.get("review_mode") or "").strip().lower()
62
+ if legacy_mode in {"manual", "review"}:
63
+ mode = "review"
64
+ elif legacy_mode in {"managed", "hybrid", "owner", "core"}:
65
+ mode = "managed"
66
+ else:
67
+ mode = "auto"
68
+
69
+ if mode not in {"auto", "review", "managed"}:
70
+ mode = "auto"
71
+
72
+ dimensions = source.get("dimensions")
73
+ if not isinstance(dimensions, dict) or not dimensions:
74
+ dimensions = _normalize_dimensions(source.get("dimension_targets"))
75
+ else:
76
+ dimensions = _normalize_dimensions(dimensions)
77
+
78
+ defaults = {
79
+ "episodic_memory": {"current": 0, "target": 90},
80
+ "autonomy": {"current": 0, "target": 80},
81
+ "proactivity": {"current": 0, "target": 70},
82
+ "self_improvement": {"current": 0, "target": 60},
83
+ "agi": {"current": 0, "target": 20},
84
+ }
85
+ merged_dimensions = dict(defaults)
86
+ merged_dimensions.update(dimensions)
87
+
88
+ normalized = dict(source)
89
+ normalized["evolution_mode"] = mode
90
+ normalized["dimensions"] = merged_dimensions
91
+ normalized["total_evolutions"] = int(source.get("total_evolutions", source.get("cycles_completed", 0)) or 0)
92
+ normalized["last_evolution"] = source.get("last_evolution", source.get("last_cycle"))
93
+ normalized["total_proposals_made"] = int(source.get("total_proposals_made", 0) or 0)
94
+ normalized["total_auto_applied"] = int(source.get("total_auto_applied", 0) or 0)
95
+ normalized["consecutive_failures"] = int(source.get("consecutive_failures", 0) or 0)
96
+ normalized["history"] = source.get("history", []) if isinstance(source.get("history"), list) else []
97
+ normalized["evolution_enabled"] = bool(source.get("evolution_enabled", True))
98
+ normalized.pop("review_mode", None)
99
+ normalized.pop("dimension_targets", None)
100
+ normalized.pop("cycles_completed", None)
101
+ normalized.pop("last_cycle", None)
102
+ return normalized
103
+
104
+
37
105
  def load_objective() -> dict:
38
106
  if OBJECTIVE_FILE.exists():
39
- return json.loads(OBJECTIVE_FILE.read_text())
40
- return {}
107
+ return normalize_objective(json.loads(OBJECTIVE_FILE.read_text()))
108
+ return normalize_objective({})
41
109
 
42
110
 
43
111
  def save_objective(obj: dict):
44
- OBJECTIVE_FILE.write_text(json.dumps(obj, indent=2, ensure_ascii=False))
112
+ OBJECTIVE_FILE.parent.mkdir(parents=True, exist_ok=True)
113
+ OBJECTIVE_FILE.write_text(json.dumps(normalize_objective(obj), indent=2, ensure_ascii=False))
45
114
 
46
115
 
47
116
  def get_week_data(db_path: str) -> dict:
@@ -196,9 +265,18 @@ def build_evolution_prompt(week_data: dict, objective: dict) -> str:
196
265
  "current_scores": {dim: m["score"] for dim, m in week_data.get("current_metrics", {}).items()},
197
266
  }
198
267
 
199
- mode = objective.get("evolution_mode", "auto")
268
+ mode = normalize_objective(objective).get("evolution_mode", "auto")
200
269
  total = objective.get("total_evolutions", 0)
201
270
  max_auto = max_auto_changes(total)
271
+ if mode == "review":
272
+ mode_desc = "review-only, nothing executes automatically"
273
+ safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/"
274
+ elif mode == "managed":
275
+ mode_desc = f"owner-managed, max {max_auto} auto-applied changes with rollback and followups"
276
+ safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/, NEXO_CODE/src, repo bin/docs/templates/tests"
277
+ else:
278
+ mode_desc = f"public auto, max {max_auto} auto-applied changes in personal safe zones"
279
+ safe_zones = "~/.nexo/scripts/, ~/.nexo/plugins/"
202
280
 
203
281
  prompt = f"""You are NEXO Evolution — the weekly self-improvement cycle.
204
282
 
@@ -212,7 +290,7 @@ WEEK SUMMARY:
212
290
  - {stats['evolution_history']} past evolution proposals
213
291
  - Current scores: {json.dumps(stats['current_scores'])}
214
292
 
215
- MODE: {mode} ({"proposals only, owner reviews" if mode == "review" else f"max {max_auto} auto-applied changes"})
293
+ MODE: {mode} ({mode_desc})
216
294
  CYCLE: #{total + 1}
217
295
 
218
296
  INVESTIGATE using these tools:
@@ -232,9 +310,11 @@ LOOK FOR:
232
310
  - Patterns in self-critique that suggest systemic issues
233
311
 
234
312
  SAFETY:
235
- - Safe zones for auto changes: ~/.nexo/scripts/, ~/.nexo/plugins/, ~/.nexo/brain/
313
+ - Safe zones for this mode: {safe_zones}
236
314
  - IMMUTABLE files (never touch): db.py, server.py, plugin_loader.py, cognitive.py, CLAUDE.md
237
315
  - Every change needs: what file, what to change, why, risk, how to verify
316
+ - AUTO changes must be deterministic. If the edit is ambiguous, risky, or needs human taste, mark it as "propose".
317
+ - In managed mode, failed AUTO changes will be rolled back automatically and turned into followups with evidence.
238
318
 
239
319
  OUTPUT FORMAT (JSON):
240
320
  {{
@@ -43,8 +43,15 @@ def handle_evolution_history(limit: int = 10) -> str:
43
43
 
44
44
  lines = [f"EVOLUTION HISTORY ({len(history)} entries):"]
45
45
  for h in history:
46
- status_icon = {"applied": "✓", "rolled_back": "✗", "proposed": "?",
47
- "accepted": "✓✓", "rejected": "✗✗"}.get(h["status"], "·")
46
+ status_icon = {
47
+ "applied": "",
48
+ "rolled_back": "↺",
49
+ "blocked": "⛔",
50
+ "proposed": "?",
51
+ "pending_review": "…",
52
+ "accepted": "✓✓",
53
+ "rejected": "✗✗",
54
+ }.get(h["status"], "·")
48
55
  lines.append(f" {status_icon} #{h['id']} [{h['classification']}] {h['dimension']}")
49
56
  lines.append(f" {h['proposal'][:100]}")
50
57
  if h.get("test_result"):
@@ -34,23 +34,6 @@ 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
37
  # ── Immutable files — NEVER touch (applies to ALL modes) ────────────────
55
38
  IMMUTABLE_FILES = {
56
39
  "db.py", "server.py", "plugin_loader.py", "nexo-watchdog.sh",
@@ -64,6 +47,52 @@ IMMUTABLE_FILES = {
64
47
  "tools_task_history.py", "tools_menu.py",
65
48
  }
66
49
 
50
+
51
+ def _repo_root() -> Path | None:
52
+ candidate = NEXO_CODE.parent
53
+ if (candidate / "package.json").exists():
54
+ return candidate
55
+ return None
56
+
57
+
58
+ def _public_safe_prefixes() -> list[str]:
59
+ return [
60
+ str(CLAUDE_DIR / "scripts") + "/",
61
+ str(CLAUDE_DIR / "plugins") + "/",
62
+ str(CLAUDE_DIR / "skills") + "/",
63
+ str(CLAUDE_DIR / "skills-runtime") + "/",
64
+ ]
65
+
66
+
67
+ def _managed_safe_prefixes() -> list[str]:
68
+ prefixes = [
69
+ str(CLAUDE_DIR / "scripts") + "/",
70
+ str(CLAUDE_DIR / "plugins") + "/",
71
+ str(CLAUDE_DIR / "brain") + "/",
72
+ str(CLAUDE_DIR / "coordination") + "/",
73
+ str(CLAUDE_DIR / "logs") + "/",
74
+ str(CLAUDE_DIR / "skills") + "/",
75
+ str(CLAUDE_DIR / "skills-core") + "/",
76
+ str(CLAUDE_DIR / "skills-runtime") + "/",
77
+ str(NEXO_CODE) + "/",
78
+ ]
79
+ repo_root = _repo_root()
80
+ if repo_root:
81
+ for rel in ("bin", "docs", "templates", "tests"):
82
+ prefixes.append(str(repo_root / rel) + "/")
83
+ return prefixes
84
+
85
+
86
+ def _normalize_mode(mode: str) -> str:
87
+ value = str(mode or "auto").strip().lower()
88
+ aliases = {
89
+ "owner": "managed",
90
+ "core": "managed",
91
+ "hybrid": "managed",
92
+ "manual": "review",
93
+ }
94
+ return aliases.get(value, value if value in {"auto", "review", "managed"} else "auto")
95
+
67
96
  # ── Claude CLI path ──────────────────────────────────────────────────────
68
97
  def _resolve_claude_cli() -> Path:
69
98
  """Find claude CLI: saved path > PATH > common locations."""
@@ -162,16 +191,18 @@ def call_claude_cli(prompt: str) -> str:
162
191
  # ── File safety validation ───────────────────────────────────────────────
163
192
  def is_safe_path(filepath: str, mode: str = "auto") -> bool:
164
193
  """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.
194
+ mode='auto' (public): restricted to personal automation surfaces.
195
+ mode='managed' (owner): broader repo/core surfaces with rollback.
196
+ mode='review': broader zones for proposal validation, but no execution.
167
197
  """
168
198
  expanded = str(Path(filepath).expanduser().resolve())
169
199
  filename = Path(expanded).name
200
+ mode = _normalize_mode(mode)
170
201
 
171
202
  if filename in IMMUTABLE_FILES:
172
203
  return False
173
204
 
174
- prefixes = AUTO_SAFE_PREFIXES if mode == "review" else AUTO_SAFE_PREFIXES_PUBLIC
205
+ prefixes = _managed_safe_prefixes() if mode in {"managed", "review"} else _public_safe_prefixes()
175
206
  for prefix in prefixes:
176
207
  resolved_prefix = str(Path(prefix).expanduser().resolve())
177
208
  if expanded.startswith(resolved_prefix):
@@ -218,13 +249,13 @@ def validate_syntax(filepath: str) -> tuple[bool, str]:
218
249
 
219
250
 
220
251
  # ── Apply a single change operation ──────────────────────────────────────
221
- def apply_change(change: dict) -> tuple[bool, str]:
252
+ def apply_change(change: dict, mode: str = "auto") -> tuple[bool, str]:
222
253
  """Apply a single file change operation. Returns (success, message)."""
223
254
  filepath = str(Path(change["file"]).expanduser())
224
255
  operation = change.get("operation", "")
225
256
  content = change.get("content", "")
226
257
 
227
- if not is_safe_path(filepath):
258
+ if not is_safe_path(filepath, mode=mode):
228
259
  return False, f"BLOCKED: {filepath} is outside safe zones or immutable"
229
260
 
230
261
  try:
@@ -269,7 +300,7 @@ def apply_change(change: dict) -> tuple[bool, str]:
269
300
 
270
301
 
271
302
  # ── Execute AUTO proposals ───────────────────────────────────────────────
272
- def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection) -> dict:
303
+ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection, mode: str = "auto") -> dict:
273
304
  """Execute an AUTO proposal with snapshot/apply/validate/rollback."""
274
305
  changes = proposal.get("changes", [])
275
306
  if not changes:
@@ -278,7 +309,7 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
278
309
  # Validate all paths first
279
310
  for change in changes:
280
311
  filepath = str(Path(change["file"]).expanduser())
281
- if not is_safe_path(filepath):
312
+ if not is_safe_path(filepath, mode=mode):
282
313
  return {"status": "blocked", "reason": f"Unsafe path: {filepath}"}
283
314
 
284
315
  # Collect files to snapshot (existing files only)
@@ -299,7 +330,7 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
299
330
  all_results = []
300
331
  try:
301
332
  for change in changes:
302
- success, msg = apply_change(change)
333
+ success, msg = apply_change(change, mode=mode)
303
334
  all_results.append(msg)
304
335
  log(f" {msg}")
305
336
  if not success:
@@ -343,57 +374,102 @@ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connecti
343
374
  log(f" Removed created file: {filepath}")
344
375
 
345
376
  return {
346
- "status": "failed",
377
+ "status": "rolled_back",
347
378
  "snapshot_ref": snapshot_ref,
348
379
  "files_changed": [],
349
380
  "test_result": f"ROLLBACK: {e}; " + "; ".join(all_results),
350
381
  }
351
382
 
352
383
 
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."""
384
+ # ── Followups for managed/review modes ──────────────────────────────────
385
+ def _insert_followup(conn: sqlite3.Connection, followup_id: str, description: str,
386
+ verification: str, due_date: str | None = None):
387
+ now_epoch = datetime.now().timestamp()
388
+ conn.execute(
389
+ "INSERT OR REPLACE INTO followups (id, description, date, status, verification, created_at, updated_at) "
390
+ "VALUES (?, ?, ?, 'PENDING', ?, ?, ?)",
391
+ (followup_id, description, due_date, verification, now_epoch, now_epoch)
392
+ )
393
+ conn.commit()
394
+
395
+
396
+ def _create_cycle_followup(conn: sqlite3.Connection, cycle_num: int,
397
+ items: list[dict], analysis: str, mode: str):
398
+ """Create a followup summarizing pending proposals or owner review items."""
357
399
  tomorrow = (date.today() + timedelta(days=1)).isoformat()
358
400
  followup_id = f"NF-EVO-C{cycle_num}"
359
401
 
360
402
  public_items = [i for i in items if i.get("scope") == "public"]
361
403
  local_items = [i for i in items if i.get("scope") != "public"]
362
404
 
363
- lines = [f"Evolution Cycle #{cycle_num} {len(items)} proposals to review."]
405
+ title = "proposals to review" if mode == "review" else "items needing attention"
406
+ lines = [f"Evolution Cycle #{cycle_num} — {len(items)} {title}."]
364
407
  lines.append(f"Analysis: {analysis[:200]}")
365
408
  lines.append("")
366
409
 
367
410
  if public_items:
368
411
  lines.append(f"FOR EVERYONE ({len(public_items)}):")
369
412
  for i, item in enumerate(public_items, 1):
370
- lines.append(f" {i}. [{item['dimension']}] {item['action'][:120]}")
413
+ status = item.get("status", "proposed").upper()
414
+ lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
371
415
  lines.append(f" Why: {item['reasoning'][:100]}")
416
+ if item.get("detail"):
417
+ lines.append(f" Detail: {item['detail'][:160]}")
372
418
  lines.append("")
373
419
 
374
420
  if local_items:
375
421
  lines.append(f"FOR YOU ONLY ({len(local_items)}):")
376
422
  for i, item in enumerate(local_items, 1):
377
- lines.append(f" {i}. [{item['dimension']}] {item['action'][:120]}")
423
+ status = item.get("status", "proposed").upper()
424
+ lines.append(f" {i}. [{status}] [{item['dimension']}] {item['action'][:120]}")
378
425
  lines.append(f" Why: {item['reasoning'][:100]}")
426
+ if item.get("detail"):
427
+ lines.append(f" Detail: {item['detail'][:160]}")
379
428
 
380
429
  description = "\n".join(lines)
381
430
 
382
431
  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)
432
+ _insert_followup(
433
+ conn,
434
+ followup_id,
435
+ description,
436
+ f"SELECT * FROM evolution_log WHERE cycle_number={cycle_num}",
437
+ due_date=tomorrow,
390
438
  )
391
- conn.commit()
392
439
  log(f" Followup {followup_id} created for {tomorrow}")
393
440
  except Exception as e:
394
441
  log(f" WARN: Failed to create followup: {e}")
395
442
 
396
443
 
444
+ def _create_failure_followup(conn: sqlite3.Connection, cycle_num: int, log_id: int,
445
+ proposal: dict, result: dict):
446
+ """Create an incident-style followup for a failed or blocked AUTO proposal."""
447
+ followup_id = f"NF-EVO-L{log_id}"
448
+ lines = [
449
+ f"Evolution AUTO proposal failed in cycle #{cycle_num}.",
450
+ f"Action: {proposal.get('action', '')[:200]}",
451
+ f"Dimension: {proposal.get('dimension', 'other')}",
452
+ f"Status: {result.get('status', 'failed')}",
453
+ f"Reason: {(result.get('reason') or result.get('test_result') or 'unknown')[:400]}",
454
+ ]
455
+ snapshot_ref = result.get("snapshot_ref")
456
+ if snapshot_ref:
457
+ lines.append(f"Snapshot: {snapshot_ref}")
458
+ description = "\n".join(lines)
459
+
460
+ try:
461
+ _insert_followup(
462
+ conn,
463
+ followup_id,
464
+ description,
465
+ f"SELECT * FROM evolution_log WHERE id={log_id}",
466
+ due_date=(date.today() + timedelta(days=1)).isoformat(),
467
+ )
468
+ log(f" Failure followup {followup_id} created")
469
+ except Exception as e:
470
+ log(f" WARN: Failed to create failure followup: {e}")
471
+
472
+
397
473
  # ── Main run ─────────────────────────────────────────────────────────────
398
474
  def run():
399
475
  log("=" * 60)
@@ -482,14 +558,12 @@ def run():
482
558
  max_auto = max_auto_changes(objective.get("total_evolutions", 0))
483
559
  auto_count = 0
484
560
  auto_applied = 0
485
- evolution_mode = objective.get("evolution_mode", "auto") # "auto" (public) or "review" (owner)
561
+ evolution_mode = _normalize_mode(objective.get("evolution_mode", "auto"))
486
562
 
487
563
  conn = sqlite3.connect(str(NEXO_DB), timeout=10)
488
564
  conn.execute("PRAGMA busy_timeout=5000")
489
565
 
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 = []
566
+ followup_items = []
493
567
 
494
568
  for p in proposals:
495
569
  classification = p.get("classification", "propose")
@@ -499,30 +573,29 @@ def run():
499
573
  scope = p.get("scope", "local") # "public" or "local"
500
574
 
501
575
  if evolution_mode == "review":
502
- # Owner mode: nothing executes, everything queued for review
503
576
  log(f" QUEUED [{scope}]: {action[:80]}")
504
577
  conn.execute(
505
578
  "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
506
579
  "reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
507
580
  (cycle_num, dimension, action, classification, reasoning, "pending_review")
508
581
  )
509
- review_items.append({
582
+ followup_items.append({
510
583
  "dimension": dimension,
511
584
  "action": action,
512
585
  "reasoning": reasoning,
513
586
  "scope": scope,
514
587
  "classification": classification,
588
+ "status": "pending_review",
515
589
  })
516
590
 
517
591
  elif classification == "auto" and auto_count < max_auto:
518
- # Public mode: execute AUTO proposals
519
592
  auto_count += 1
520
593
  log(f" AUTO #{auto_count}/{max_auto}: {action[:80]}")
521
594
 
522
- result = execute_auto_proposal(p, cycle_num, conn)
595
+ result = execute_auto_proposal(p, cycle_num, conn, mode=evolution_mode)
523
596
  status = result["status"]
524
597
 
525
- conn.execute(
598
+ cur = conn.execute(
526
599
  "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
527
600
  "reasoning, status, files_changed, snapshot_ref, test_result) "
528
601
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -531,16 +604,20 @@ def run():
531
604
  result.get("snapshot_ref", ""),
532
605
  result.get("test_result", ""))
533
606
  )
607
+ log_id = cur.lastrowid
534
608
 
535
609
  if status == "applied":
536
610
  auto_applied += 1
537
611
  log(f" APPLIED successfully")
538
612
  elif status == "blocked":
539
- log(f" BLOCKED: {result.get('test_result', '')}")
613
+ detail = result.get("reason") or result.get("test_result", "")
614
+ log(f" BLOCKED: {detail[:100]}")
615
+ _create_failure_followup(conn, cycle_num, log_id, p, result)
540
616
  elif status == "skipped":
541
617
  log(f" SKIPPED: {result.get('reason', '')}")
542
618
  else:
543
- log(f" FAILED: {result.get('test_result', '')[:100]}")
619
+ log(f" ROLLED BACK: {result.get('test_result', '')[:100]}")
620
+ _create_failure_followup(conn, cycle_num, log_id, p, result)
544
621
 
545
622
  else:
546
623
  # PROPOSE or over auto limit
@@ -555,12 +632,20 @@ def run():
555
632
  "reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
556
633
  (cycle_num, dimension, action, classification, reasoning, "proposed")
557
634
  )
635
+ if evolution_mode in {"review", "managed"}:
636
+ followup_items.append({
637
+ "dimension": dimension,
638
+ "action": action,
639
+ "reasoning": reasoning,
640
+ "scope": scope,
641
+ "classification": classification,
642
+ "status": "proposed",
643
+ })
558
644
 
559
645
  conn.commit()
560
646
 
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", ""))
647
+ if evolution_mode in {"review", "managed"} and followup_items:
648
+ _create_cycle_followup(conn, cycle_num, followup_items, response.get("analysis", ""), evolution_mode)
564
649
 
565
650
  # Update metrics
566
651
  scores = response.get("dimension_scores", {})
@@ -591,6 +676,7 @@ def run():
591
676
  objective.setdefault("history", []).insert(0, {
592
677
  "cycle": cycle_num,
593
678
  "date": str(date.today()),
679
+ "mode": evolution_mode,
594
680
  "proposals": len(proposals),
595
681
  "auto_count": auto_count,
596
682
  "auto_applied": auto_applied,